From 3c860b3668db2ebe54147fb36111db56fa975348 Mon Sep 17 00:00:00 2001
From: Elwyn Benson <ebenson@gitlab.com>
Date: Mon, 31 Jul 2023 10:12:02 +0000
Subject: [PATCH] Remove extra handling of bad data in SingleStats

Malformed query / response should trigger an error instead of silently
failing and falling back to 0
---
 .../data_sources/cube_analytics.js            |  17 +-
 .../instrumentation_instructions.vue          |  22 +--
 .../data_sources/cube_analytics_spec.js       | 159 ++++++++++--------
 .../analytics_dashboards/mock_data.js         |  10 ++
 4 files changed, 110 insertions(+), 98 deletions(-)

diff --git a/ee/app/assets/javascripts/analytics/analytics_dashboards/data_sources/cube_analytics.js b/ee/app/assets/javascripts/analytics/analytics_dashboards/data_sources/cube_analytics.js
index e73aa103a1606..76c11e4ed636d 100644
--- a/ee/app/assets/javascripts/analytics/analytics_dashboards/data_sources/cube_analytics.js
+++ b/ee/app/assets/javascripts/analytics/analytics_dashboards/data_sources/cube_analytics.js
@@ -2,18 +2,19 @@ import { CubejsApi, HttpTransport } from '@cubejs-client/core';
 import { convertToSnakeCase } from '~/lib/utils/text_utility';
 import { pikadayToString } from '~/lib/utils/datetime_utility';
 import csrf from '~/lib/utils/csrf';
+import {
+  EVENTS_TABLE_NAME,
+  SESSIONS_TABLE_NAME,
+} from 'ee/analytics/analytics_dashboards/constants';
 
 // This can be any value because the cube proxy adds the real API token.
 const CUBE_API_TOKEN = '1';
 const PRODUCT_ANALYTICS_CUBE_PROXY = '/api/v4/projects/:id/product_analytics/request';
-const DEFAULT_COUNT_KEY = 'TrackedEvents.count';
 
 // Filter measurement types must be lowercase
 export const DATE_RANGE_FILTER_DIMENSIONS = {
-  sessions: 'Sessions.startAt',
-  trackedevents: 'TrackedEvents.utcTime',
-  snowplowtrackedevents: 'SnowplowTrackedEvents.derivedTstamp',
-  snowplowsessions: 'SnowplowSessions.startAt',
+  snowplowtrackedevents: `${EVENTS_TABLE_NAME}.derivedTstamp`,
+  snowplowsessions: `${SESSIONS_TABLE_NAME}.startAt`,
 };
 
 const convertToCommonChartFormat = (resultSet) => {
@@ -68,11 +69,7 @@ const convertToSingleValue = (resultSet, query) => {
   const [measure] = query?.measures ?? [];
   const [row] = resultSet.rawData();
 
-  if (!row) {
-    return 0;
-  }
-
-  return row[measure ?? DEFAULT_COUNT_KEY] ?? Object.values(row)[0] ?? 0;
+  return row[measure] ?? 0;
 };
 
 const buildDateRangeFilter = (query, queryOverrides, { startDate, endDate }) => {
diff --git a/ee/app/assets/javascripts/product_analytics/onboarding/components/instrumentation_instructions.vue b/ee/app/assets/javascripts/product_analytics/onboarding/components/instrumentation_instructions.vue
index 06f100ef839dd..b2bb71f6c2e8a 100644
--- a/ee/app/assets/javascripts/product_analytics/onboarding/components/instrumentation_instructions.vue
+++ b/ee/app/assets/javascripts/product_analytics/onboarding/components/instrumentation_instructions.vue
@@ -31,16 +31,6 @@ export default {
       required: true,
     },
   },
-  computed: {
-    instructions() {
-      return {
-        install: INSTALL_NPM_PACKAGE,
-        import: IMPORT_NPM_PACKAGE,
-        init: INIT_TRACKING,
-        htmlSetup: HTML_SCRIPT_SETUP,
-      };
-    },
-  },
   i18n: {
     sdkClientsTitle: s__('ProductAnalytics|SDK clients'),
     sdkHost: s__('ProductAnalytics|SDK host'),
@@ -68,6 +58,10 @@ export default {
     ),
   },
   BROWSER_SDK_DOCS_URL,
+  INSTALL_NPM_PACKAGE,
+  IMPORT_NPM_PACKAGE,
+  INIT_TRACKING,
+  HTML_SCRIPT_SETUP,
 };
 </script>
 
@@ -80,13 +74,13 @@ export default {
         <h5 class="gl-mb-5">{{ $options.i18n.jsModuleTitle }}</h5>
 
         <strong class="gl-display-block gl-mb-3">{{ $options.i18n.addNpmPackage }}</strong>
-        <pre class="gl-mb-5">{{ instructions.install }}</pre>
+        <pre class="gl-mb-5">{{ $options.INSTALL_NPM_PACKAGE }}</pre>
         <strong class="gl-display-block gl-mt-5 gl-mb-3">{{
           $options.i18n.importNpmPackage
         }}</strong>
-        <pre class="gl-mb-5">{{ instructions.import }}</pre>
+        <pre class="gl-mb-5">{{ $options.IMPORT_NPM_PACKAGE }}</pre>
         <strong class="gl-display-block gl-mt-5 gl-mb-3">{{ $options.i18n.initNpmPackage }}</strong>
-        <pre class="gl-mb-5"><gl-sprintf :message="instructions.init">
+        <pre class="gl-mb-5"><gl-sprintf :message="$options.INIT_TRACKING">
           <template #appId><span>{{ trackingKey }}</span></template>
           <template #host><span>{{ collectorHost }}</span></template>
         </gl-sprintf></pre>
@@ -97,7 +91,7 @@ export default {
         <strong class="gl-display-block gl-mb-3">{{
           $options.i18n.htmlScriptTagDescription
         }}</strong>
-        <pre class="gl-mb-5"><gl-sprintf :message="instructions.htmlSetup">
+        <pre class="gl-mb-5"><gl-sprintf :message="$options.HTML_SCRIPT_SETUP">
           <template #appId><span>{{ trackingKey }}</span></template>
           <template #host><span>{{ collectorHost }}</span></template>
         </gl-sprintf></pre>
diff --git a/ee/spec/frontend/analytics/analytics_dashboards/components/data_sources/cube_analytics_spec.js b/ee/spec/frontend/analytics/analytics_dashboards/components/data_sources/cube_analytics_spec.js
index 6e054ba04c242..f2882d71d7fd4 100644
--- a/ee/spec/frontend/analytics/analytics_dashboards/components/data_sources/cube_analytics_spec.js
+++ b/ee/spec/frontend/analytics/analytics_dashboards/components/data_sources/cube_analytics_spec.js
@@ -1,7 +1,12 @@
 import { CubejsApi, HttpTransport, __setMockLoad } from '@cubejs-client/core';
 import { fetch } from 'ee/analytics/analytics_dashboards/data_sources/cube_analytics';
 import { pikadayToString } from '~/lib/utils/datetime_utility';
-import { mockResultSet, mockFilters, mockTableWithLinksResultSet } from '../../mock_data';
+import {
+  mockResultSet,
+  mockFilters,
+  mockTableWithLinksResultSet,
+  mockResultSetWithNullValues,
+} from '../../mock_data';
 
 const mockLoad = jest.fn().mockImplementation(() => mockResultSet);
 
@@ -48,97 +53,103 @@ describe('Cube Analytics Data Source', () => {
     });
 
     describe('formats the data', () => {
-      it('returns the expected data format for line charts', async () => {
-        const result = await fetch({ projectId, visualizationType, query });
-
-        expect(result[0]).toMatchObject({
-          data: [
-            ['2022-11-09T00:00:00.000', 55],
-            ['2022-11-10T00:00:00.000', 14],
-          ],
-          name: 'pageview, SnowplowTrackedEvents Count',
+      describe('charts', () => {
+        it('returns the expected data format for line charts', async () => {
+          const result = await fetch({ projectId, visualizationType, query });
+
+          expect(result[0]).toMatchObject({
+            data: [
+              ['2022-11-09T00:00:00.000', 55],
+              ['2022-11-10T00:00:00.000', 14],
+            ],
+            name: 'pageview, SnowplowTrackedEvents Count',
+          });
         });
-      });
 
-      it('returns the expected data format for column charts', async () => {
-        const result = await fetch({ projectId, visualizationType: 'ColumnChart', query });
+        it('returns the expected data format for column charts', async () => {
+          const result = await fetch({ projectId, visualizationType: 'ColumnChart', query });
 
-        expect(result[0]).toMatchObject({
-          data: [
-            ['2022-11-09T00:00:00.000', 55],
-            ['2022-11-10T00:00:00.000', 14],
-          ],
-          name: 'pageview, SnowplowTrackedEvents Count',
+          expect(result[0]).toMatchObject({
+            data: [
+              ['2022-11-09T00:00:00.000', 55],
+              ['2022-11-10T00:00:00.000', 14],
+            ],
+            name: 'pageview, SnowplowTrackedEvents Count',
+          });
         });
       });
 
-      it('returns the expected data format for data tables', async () => {
-        const result = await fetch({ projectId, visualizationType: 'DataTable', query });
+      describe('data tables', () => {
+        it('returns the expected data format for', async () => {
+          const result = await fetch({ projectId, visualizationType: 'DataTable', query });
 
-        expect(result[0]).toMatchObject({
-          count: '55',
-          event_type: 'pageview',
-          utc_time: '2022-11-09T00:00:00.000',
+          expect(result[0]).toMatchObject({
+            count: '55',
+            event_type: 'pageview',
+            utc_time: '2022-11-09T00:00:00.000',
+          });
         });
-      });
-
-      it('returns the expected data format for data tables when links config is defined', async () => {
-        mockLoad.mockImplementationOnce(() => mockTableWithLinksResultSet);
 
-        const result = await fetch({
-          projectId,
-          visualizationType: 'DataTable',
-          query: {
-            measures: ['SnowplowTrackedEvents.pageViewsCount'],
-            dimensions: ['SnowplowTrackedEvents.docPath', 'SnowplowTrackedEvents.url'],
-          },
-          visualizationOptions: {
-            links: [
-              {
-                text: 'SnowplowTrackedEvents.docPath',
-                href: 'SnowplowTrackedEvents.url',
-              },
-            ],
-          },
-        });
-
-        expect(result[0]).toMatchObject({
-          page_views_count: '1',
-          doc_path: {
-            text: '/foo',
-            href: 'https://example.com/foo',
-          },
+        it('returns the expected data format when links config is defined', async () => {
+          mockLoad.mockImplementationOnce(() => mockTableWithLinksResultSet);
+
+          const result = await fetch({
+            projectId,
+            visualizationType: 'DataTable',
+            query: {
+              measures: ['SnowplowTrackedEvents.pageViewsCount'],
+              dimensions: ['SnowplowTrackedEvents.docPath', 'SnowplowTrackedEvents.url'],
+            },
+            visualizationOptions: {
+              links: [
+                {
+                  text: 'SnowplowTrackedEvents.docPath',
+                  href: 'SnowplowTrackedEvents.url',
+                },
+              ],
+            },
+          });
+
+          expect(result[0]).toMatchObject({
+            page_views_count: '1',
+            doc_path: {
+              text: '/foo',
+              href: 'https://example.com/foo',
+            },
+          });
         });
       });
 
-      it('returns the expected data format for single stats', async () => {
-        const result = await fetch({ projectId, visualizationType: 'SingleStat', query });
+      describe('single stats', () => {
+        it('returns the expected data format', async () => {
+          const result = await fetch({ projectId, visualizationType: 'SingleStat', query });
 
-        expect(result).toBe('36');
-      });
-
-      it('returns the expected data format for single stats with custom measure', async () => {
-        const override = { measures: ['SnowplowTrackedEvents.url'] };
-        const result = await fetch({
-          projectId,
-          visualizationType: 'SingleStat',
-          query,
-          queryOverrides: override,
+          expect(result).toBe('36');
         });
 
-        expect(result).toBe('https://example.com/us');
-      });
+        it('returns the expected data format with custom measure', async () => {
+          const override = { measures: ['SnowplowTrackedEvents.url'] };
+          const result = await fetch({
+            projectId,
+            visualizationType: 'SingleStat',
+            query,
+            queryOverrides: override,
+          });
 
-      it('returns the expected data format for single stats when the measure is unknown', async () => {
-        const override = { measures: ['unknown'] };
-        const result = await fetch({
-          projectId,
-          visualizationType: 'SingleStat',
-          query,
-          queryOverrides: override,
+          expect(result).toBe('https://example.com/us');
         });
 
-        expect(result).toBe('en-US');
+        it('returns 0 when the measure is null', async () => {
+          mockLoad.mockImplementationOnce(() => mockResultSetWithNullValues);
+
+          const result = await fetch({
+            projectId,
+            visualizationType: 'SingleStat',
+            query,
+          });
+
+          expect(result).toBe(0);
+        });
       });
     });
   });
diff --git a/ee/spec/frontend/analytics/analytics_dashboards/mock_data.js b/ee/spec/frontend/analytics/analytics_dashboards/mock_data.js
index 6096578aa65e2..ce5c93ef358fe 100644
--- a/ee/spec/frontend/analytics/analytics_dashboards/mock_data.js
+++ b/ee/spec/frontend/analytics/analytics_dashboards/mock_data.js
@@ -306,6 +306,16 @@ export const mockTableWithLinksResultSet = {
   ],
 };
 
+export const mockResultSetWithNullValues = {
+  rawData: () => [
+    {
+      'SnowplowTrackedEvents.userLanguage': null,
+      'SnowplowTrackedEvents.count': null,
+      'SnowplowTrackedEvents.url': null,
+    },
+  ],
+};
+
 export const mockFilters = {
   startDate: new Date('2015-01-01'),
   endDate: new Date('2016-01-01'),
-- 
GitLab