diff --git a/frontend/__snapshots__/scenes-app-notebooks--recordings-playlist.png b/frontend/__snapshots__/scenes-app-notebooks--recordings-playlist.png index 276741ceaec32..be8910d314d5b 100644 Binary files a/frontend/__snapshots__/scenes-app-notebooks--recordings-playlist.png and b/frontend/__snapshots__/scenes-app-notebooks--recordings-playlist.png differ diff --git a/frontend/src/lib/constants.tsx b/frontend/src/lib/constants.tsx index 662c7aed551a6..090d275295eea 100644 --- a/frontend/src/lib/constants.tsx +++ b/frontend/src/lib/constants.tsx @@ -161,6 +161,7 @@ export const FEATURE_FLAGS = { APPS_AND_EXPORTS_UI: 'apps-and-exports-ui', // owner: @benjackwhite SESSION_REPLAY_CORS_PROXY: 'session-replay-cors-proxy', // owner: #team-monitoring HOGQL_INSIGHTS: 'hogql-insights', // owner: @mariusandra + HOGQL_INSIGHT_LIVE_COMPARE: 'hogql-insight-live-compare', // owner: @mariusandra WEBHOOKS_DENYLIST: 'webhooks-denylist', // owner: #team-pipeline SURVEYS_MULTIPLE_QUESTIONS: 'surveys-multiple-questions', // owner: @liyiy SURVEYS_RESULTS_VISUALIZATIONS: 'surveys-results-visualizations', // owner: @jurajmajerik diff --git a/frontend/src/lib/utils.tsx b/frontend/src/lib/utils.tsx index 3db2d4a4e8dbe..be48d5618e0e5 100644 --- a/frontend/src/lib/utils.tsx +++ b/frontend/src/lib/utils.tsx @@ -1827,3 +1827,27 @@ export function shouldCancelQuery(error: any): boolean { // the query will continue running in ClickHouse return error.name === 'AbortError' || error.message?.name === 'AbortError' || error.status === 504 } + +export function flattenObject(ob: Record): Record { + const toReturn = {} + + for (const i in ob) { + if (!ob.hasOwnProperty(i)) { + continue + } + + if (typeof ob[i] == 'object') { + const flatObject = flattenObject(ob[i]) + for (const x in flatObject) { + if (!flatObject.hasOwnProperty(x)) { + continue + } + + toReturn[i + '.' + x] = flatObject[x] + } + } else { + toReturn[i] = ob[i] + } + } + return toReturn +} diff --git a/frontend/src/queries/query.ts b/frontend/src/queries/query.ts index 646cfc180feff..df71c0b1cef4b 100644 --- a/frontend/src/queries/query.ts +++ b/frontend/src/queries/query.ts @@ -12,6 +12,7 @@ import { isInsightVizNode, isQueryWithHogQLSupport, isPersonsQuery, + isLifecycleQuery, } from './utils' import api, { ApiMethodOptions } from 'lib/api' import { getCurrentTeamId } from 'lib/utils/logics' @@ -25,7 +26,7 @@ import { isStickinessFilter, isTrendsFilter, } from 'scenes/insights/sharedUtils' -import { toParams } from 'lib/utils' +import { flattenObject, toParams } from 'lib/utils' import { queryNodeToFilter } from './nodes/InsightQuery/utils/queryNodeToFilter' import { now } from 'lib/dayjs' import { currentSessionId } from 'lib/internalMetrics' @@ -108,25 +109,34 @@ export async function query( const hogQLInsightsFlagEnabled = Boolean( featureFlagLogic.findMounted()?.values.featureFlags?.[FEATURE_FLAGS.HOGQL_INSIGHTS] ) + const hogQLInsightsLiveCompareEnabled = Boolean( + featureFlagLogic.findMounted()?.values.featureFlags?.[FEATURE_FLAGS.HOGQL_INSIGHT_LIVE_COMPARE] + ) + + async function fetchLegacyInsights(): Promise> { + if (!isInsightQueryNode(queryNode)) { + throw new Error('fetchLegacyInsights called with non-insight query. Should be unreachable.') + } + const filters = queryNodeToFilter(queryNode) + const params = { + ...filters, + ...(refresh ? { refresh: true } : {}), + client_query_id: queryId, + session_id: currentSessionId(), + } + const [resp] = await legacyInsightQuery({ + filters: params, + currentTeamId: getCurrentTeamId(), + methodOptions, + refresh, + }) + response = await resp.json() + return response + } try { if (isPersonsNode(queryNode)) { response = await api.get(getPersonsEndpoint(queryNode), methodOptions) - } else if (isInsightQueryNode(queryNode) && !(hogQLInsightsFlagEnabled && isQueryWithHogQLSupport(queryNode))) { - const filters = queryNodeToFilter(queryNode) - const params = { - ...filters, - ...(refresh ? { refresh: true } : {}), - client_query_id: queryId, - session_id: currentSessionId(), - } - const [resp] = await legacyInsightQuery({ - filters: params, - currentTeamId: getCurrentTeamId(), - methodOptions, - refresh, - }) - response = await resp.json() } else if (isTimeToSeeDataQuery(queryNode)) { response = await api.query( { @@ -138,6 +148,73 @@ export async function query( }, methodOptions ) + } else if (isInsightQueryNode(queryNode)) { + if (hogQLInsightsFlagEnabled && isQueryWithHogQLSupport(queryNode)) { + if (hogQLInsightsLiveCompareEnabled) { + let legacyResponse + ;[response, legacyResponse] = await Promise.all([ + api.query(queryNode, methodOptions, queryId, refresh), + fetchLegacyInsights(), + ]) + + const res1 = response?.result || response?.results + const res2 = legacyResponse?.result || legacyResponse?.results + + if (isLifecycleQuery(queryNode)) { + // Results don't come back in a predetermined order for the legacy lifecycle insight + const order = { new: 1, returning: 2, resurrecting: 3, dormant: 4 } + res1.sort((a: any, b: any) => order[a.status] - order[b.status]) + res2.sort((a: any, b: any) => order[a.status] - order[b.status]) + } + + const results = flattenObject(res1) + const legacyResults = flattenObject(res2) + const sortedKeys = Array.from(new Set([...Object.keys(results), ...Object.keys(legacyResults)])) + .filter((key) => !key.includes('.persons_urls.')) + .sort() + const tableData = [['', 'key', 'HOGQL', 'LEGACY']] + let matchCount = 0 + let mismatchCount = 0 + for (const key of sortedKeys) { + if (results[key] === legacyResults[key]) { + matchCount++ + } else { + mismatchCount++ + } + tableData.push([ + results[key] === legacyResults[key] ? '✅' : '🚨', + key, + results[key], + legacyResults[key], + ]) + } + const symbols = mismatchCount === 0 ? '🍀🍀🍀' : '🏎️🏎️🏎' + // eslint-disable-next-line no-console + console.log(`${symbols} Insight Race ${symbols}`, { + query: queryNode, + duration: performance.now() - startTime, + hogqlResults: results, + legacyResults: legacyResults, + equal: mismatchCount === 0, + response, + legacyResponse, + }) + // eslint-disable-next-line no-console + console.groupCollapsed( + `Results: ${mismatchCount === 0 ? '✅✅✅' : '✅'} ${matchCount}${ + mismatchCount > 0 ? ` 🚨🚨🚨${mismatchCount}` : '' + }` + ) + // eslint-disable-next-line no-console + console.table(tableData) + // eslint-disable-next-line no-console + console.groupEnd() + } else { + response = await api.query(queryNode, methodOptions, queryId, refresh) + } + } else { + response = await fetchLegacyInsights() + } } else { response = await api.query(queryNode, methodOptions, queryId, refresh) if (isHogQLQuery(queryNode) && response && typeof response === 'object') { diff --git a/posthog/hogql_queries/insights/lifecycle_query_runner.py b/posthog/hogql_queries/insights/lifecycle_query_runner.py index 49d85e094ed48..7b1e5579ab650 100644 --- a/posthog/hogql_queries/insights/lifecycle_query_runner.py +++ b/posthog/hogql_queries/insights/lifecycle_query_runner.py @@ -147,10 +147,33 @@ def calculate(self): for item in val[0] ] - label = "{} - {}".format("", val[2]) # entity.name + # legacy response compatibility object + action_object = {} + label = "{} - {}".format("", val[2]) + if isinstance(self.query.series[0], ActionsNode): + action = Action.objects.get(pk=int(self.query.series[0].id), team=self.team) + label = "{} - {}".format(action.name, val[2]) + action_object = { + "id": str(action.pk), + "name": action.name, + "type": "actions", + "order": 0, + "math": "total", + } + elif isinstance(self.query.series[0], EventsNode): + label = "{} - {}".format(self.query.series[0].event, val[2]) + action_object = { + "id": self.query.series[0].event, + "name": self.query.series[0].event, + "type": "events", + "order": 0, + "math": "total", + } + additional_values = {"label": label, "status": val[2]} res.append( { + "action": action_object, "data": [float(c) for c in counts], "count": float(sum(counts)), "labels": labels, diff --git a/posthog/hogql_queries/insights/test/test_lifecycle_query_runner.py b/posthog/hogql_queries/insights/test/test_lifecycle_query_runner.py index 1dba61d970e6c..df4dcbf350b11 100644 --- a/posthog/hogql_queries/insights/test/test_lifecycle_query_runner.py +++ b/posthog/hogql_queries/insights/test/test_lifecycle_query_runner.py @@ -146,7 +146,7 @@ def test_lifecycle_query_whole_range(self): "2020-01-18", "2020-01-19", ], - "label": " - new", + "label": "$pageview - new", "labels": [ "9-Jan-2020", "10-Jan-2020", @@ -161,6 +161,13 @@ def test_lifecycle_query_whole_range(self): "19-Jan-2020", ], "status": "new", + "action": { + "id": "$pageview", + "math": "total", + "name": "$pageview", + "order": 0, + "type": "events", + }, }, { "count": 2.0, @@ -190,7 +197,7 @@ def test_lifecycle_query_whole_range(self): "2020-01-18", "2020-01-19", ], - "label": " - returning", + "label": "$pageview - returning", "labels": [ "9-Jan-2020", "10-Jan-2020", @@ -205,6 +212,13 @@ def test_lifecycle_query_whole_range(self): "19-Jan-2020", ], "status": "returning", + "action": { + "id": "$pageview", + "math": "total", + "name": "$pageview", + "order": 0, + "type": "events", + }, }, { "count": 4.0, @@ -234,7 +248,7 @@ def test_lifecycle_query_whole_range(self): "2020-01-18", "2020-01-19", ], - "label": " - resurrecting", + "label": "$pageview - resurrecting", "labels": [ "9-Jan-2020", "10-Jan-2020", @@ -249,6 +263,13 @@ def test_lifecycle_query_whole_range(self): "19-Jan-2020", ], "status": "resurrecting", + "action": { + "id": "$pageview", + "math": "total", + "name": "$pageview", + "order": 0, + "type": "events", + }, }, { "count": -7.0, @@ -278,7 +299,7 @@ def test_lifecycle_query_whole_range(self): "2020-01-18", "2020-01-19", ], - "label": " - dormant", + "label": "$pageview - dormant", "labels": [ "9-Jan-2020", "10-Jan-2020", @@ -293,6 +314,13 @@ def test_lifecycle_query_whole_range(self): "19-Jan-2020", ], "status": "dormant", + "action": { + "id": "$pageview", + "math": "total", + "name": "$pageview", + "order": 0, + "type": "events", + }, }, ], response.results,