lib/looker.ts (321 lines of code) (raw):

import { IDashboardElement, IWriteQuery } from "@looker/sdk"; import { SDK } from "./sdk"; import { getDashboardIdForSurface, getSurfaceData } from "./messageUtils"; import { getLookerSubmissionTimestampDateFilter } from "./lookerUtils"; import { Platform } from "./types"; export type CTRData = { ctrPercent: number; impressions: number; }; /** * Safely formats a CTR percentage value with consistent decimal precision * * @param value - The raw CTR rate (typically between 0 and 1) * @returns The formatted CTR percentage with 2 decimal places * * This function is used throughout the codebase to ensure consistent formatting * of CTR percentages. It's necessary because we need consistent decimal * precision for CTR percentages in the UI and tests. * * Using Math.round with multiplier/divisor instead of toFixed() to avoid * floating-point precision issues in JavaScript. This approach ensures * consistent decimal precision without string conversion artifacts: * * 1. Multiply by 10000 to shift decimal point right by 4 places * 2. Round to nearest integer to handle the 2-decimal precision we want * 3. Divide by 100 to shift decimal point left by 2 places */ export function getSafeCtrPercent(value: number): number { return Math.round(value * 10000) / 100; } export async function getDashboardElement0( template: string, ): Promise<IDashboardElement> { const dashboardId = getDashboardIdForSurface(template); // XXX maybe switch this out for the more performant dashboard_element (see // https://mozilla.cloud.looker.com/extensions/marketplace_extension_api_explorer::api-explorer/4.0/methods/Dashboard/dashboard_element // for more info). const elements: IDashboardElement[] = await SDK.ok( // XXX whether search_dashboard_elements is a net win here isn't // clear, but the code is working, so I'm inclined to leave it alone for now. SDK.search_dashboard_elements({ dashboard_id: dashboardId, title: "CTR and User Profiles Impressed", fields: "query", }), ); return elements[0]; } /** * @returns the query results in JSON format for the Look query with lookId */ export async function runLookQuery(lookId: string): Promise<string> { const results = await SDK.ok( SDK.run_look({ look_id: lookId, result_format: "json", }), ); return results; } /** * @param template the message template * @param filters an object containing any filters used in the Looker query (eg. * channel, templates, experiment, branch) * @param startDate the experiment start date * @param endDate the experiment proposed end date * @returns the result of the query that is created by the given filters and * filter_expression, and the appropriate template and submission timestamp */ export async function runQueryForSurface( template: string, filters: any, startDate?: string | null, endDate?: string | null, ): Promise<any> { const element0 = await getDashboardElement0(template); const origQuery = element0.query as IWriteQuery; // take the query from the original dashboard const newQueryBody = structuredClone(origQuery); delete newQueryBody.client_id; // must be unique per-query const submission_timestamp_date = getLookerSubmissionTimestampDateFilter( startDate, endDate, ); // override the filters newQueryBody.filters = Object.assign( { [getSurfaceData(template).lookerDateFilterPropertyName]: submission_timestamp_date, }, filters, ); const newQuery = await SDK.ok(SDK.create_query(newQueryBody)); const result = await SDK.ok( SDK.run_query({ query_id: newQuery.id!, result_format: "json", }), ); return result; } /** * @param id the events_count.message_id required for running the looker * query to retrieve CTR metrics * @param platform the message platform * @param template the message template * @param channel the normalized channel * @param experiment the experiment slug * @param branch the branch slug * @param startDate the experiment start date * @param endDate the experiment proposed end date * @returns a CTR percent value for a message if the Looker query results are * defined */ export async function getCTRPercentData( id: string, platform: Platform, template: string, channel?: string, experiment?: string, branch?: string, startDate?: string | null, endDate?: string | null, ): Promise<CTRData | undefined> { switch (platform) { case "fenix": return getAndroidCTRPercentData( id, template, channel, experiment, branch, startDate, endDate, ); default: return getDesktopCTRPercentData( id, template, channel, experiment, branch, startDate, endDate, ); } } export async function getAndroidCTRPercentData( id: string, template: string, channel?: string, experiment?: string, branch?: string, startDate?: string | null, endDate?: string | null, ): Promise<CTRData | undefined> { // XXX the filters are currently defined to match the filters in getDashboard. // It would be more ideal to consider a different approach when definining // those filters to sync up the data in both places. Non-trivial changes to // this code are worth applying some manual performance checking. // If not using survey template, warn and return undefined since only survey is supported if (template !== "survey") { console.warn( `Warning: Unsupported Android surface "${template}". Only "survey" is currently supported for Android.`, ); return undefined; } // Only proceed with the query for survey template const queryResult = await runQueryForSurface( template, { "events.normalized_channel": channel, "events_unnested_table__ping_info__experiments.key": experiment, "events_unnested_table__ping_info__experiments.value__branch": branch, "events.sample_id": "to 10", // XXX This is equal to Sample ID <= 10 "events.event_category": "messaging", // XXX this should be updated once we support more Android Looker dashboards "events_unnested_table__event_extra.value": id.slice(0, -5) + "%", }, startDate, endDate, ); if (queryResult?.length > 0) { // CTR percents will have 2 decimal places since this is what is expected // from Experimenter analyses. const clientCount = queryResult[0]["events.client_count"]; const eventName = clientCount["events.event_name"]; const impressions = eventName["message_shown"]; const primaryRate = queryResult[0].primary_rate; const ctrPercent = getSafeCtrPercent(primaryRate); return { ctrPercent: ctrPercent, impressions: impressions * 10, // We need to extrapolate real numbers for the 10% sample }; } // Return undefined if no query results return undefined; } export async function getDesktopCTRPercentData( id: string, template: string, channel?: string, experiment?: string, branch?: string, startDate?: string | null, endDate?: string | null, ): Promise<CTRData | undefined> { // XXX the filters are currently defined to match the filters in getDashboard. // It would be more ideal to consider a different approach when definining // those filters to sync up the data in both places. Non-trivial changes to // this code are worth applying some manual performance checking. let queryResult; if (template === "infobar") { queryResult = await runQueryForSurface( template, { "messaging_system.metrics__text2__messaging_system_message_id": id, "messaging_system.normalized_channel": channel, "messaging_system.metrics__string__messaging_system_ping_type": template, "messaging_system__ping_info__experiments.key": experiment, "messaging_system__ping_info__experiments.value__branch": branch, }, startDate, endDate, ); } else { queryResult = await runQueryForSurface( template, { "event_counts.message_id": "%" + id + "%", "event_counts.normalized_channel": channel, "onboarding_v1__experiments.experiment": experiment, "onboarding_v1__experiments.branch": branch, }, startDate, endDate, ); } if (queryResult?.length > 0) { // CTR percents will have 2 decimal places since this is what is expected // from Experimenter analyses. let impressions; if (template === "infobar") { const pingCount = queryResult[0]["messaging_system.ping_count"]; const events = pingCount["messaging_system.metrics__string__messaging_system_event"]; impressions = events["IMPRESSION"]; } else { const userCount = queryResult[0]["event_counts.user_count"]; const action = userCount.action; impressions = action[" Impression"]; } const primaryRate = queryResult[0].primary_rate; const ctrPercent = getSafeCtrPercent(primaryRate); return { ctrPercent: ctrPercent, impressions: impressions, }; } return undefined; } /** * Removes any messages inside `data` for ids specified in the removeMessages * array, ids with substring "test", and ids that are single characters. */ export function cleanLookerData(data: any): any { let cleanData = data.filter((messageDef: any) => { const removeMessages = ["undefined", "", "n/a", null, "DEFAULT_ID"]; const messageId = messageDef[ "messaging_system.metrics__text2__messaging_system_message_id" ]; if (removeMessages.includes(messageId)) { return false; } else if (messageId.toLowerCase().includes("test")) { return false; } else if (messageId.length === 1) { return false; } else { return true; } }); return cleanData; } /** * Appends any messages from `newLookerData` into `originalData` with an id * that does not already exist in `originalData`. Updates the templates for * any message that need some clean up. * * The message data in `newLookerData` has properties * "messaging_system.metrics__text2__messaging_system_message_id" and * "messaging_system.metrics__string__messaging_system_ping_type" to represent * the message id and template. Before appending these messages into * `originalData`, we must clean up the objects to have properties "id" and * "template" instead, and exclude any other properties that do not currently * provide any value. */ export function mergeLookerData(originalData: any, newLookerData: any): any { for (let i = 0; i < newLookerData.length; i++) { const lookerDataMessageId = newLookerData[i][ "messaging_system.metrics__text2__messaging_system_message_id" ]; const lookerDataMessageTemplate = newLookerData[i][ "messaging_system.metrics__string__messaging_system_ping_type" ]; // Check if the id for newLookerData[i] already exists in originalData if (originalData.find((x: any) => x.id === lookerDataMessageId)) { continue; } // `hidePreview` is added because the message data from Looker does not // have enough information to enable message previews correctly and we // must manually hide the previews for now. let clean_looker_object = { id: lookerDataMessageId, template: lookerDataMessageTemplate, hidePreview: true, }; // This is a heuristic for updating templates for RTAMO messages. This can // fail if an RTAMO message does not happen to include this substring. if (clean_looker_object.id.includes("RTAMO")) { clean_looker_object.template = "rtamo"; } // This is a specific check for updating the template for the FOCUS_PROMO // message. if (clean_looker_object.id === "FOCUS_PROMO") { clean_looker_object.template = "pb_newtab"; } // This is a specific check for updating the template for the // MILESTONE_MESSAGE message. if (clean_looker_object.id === "MILESTONE_MESSAGE") { clean_looker_object.template = "milestone_message"; } // This is a heuristic for updating templates for feature callouts. Most // messages with a null template after the other heuristics should be // feature callouts, but the check can still fail if we missed an edge case. if (clean_looker_object.template === null) { clean_looker_object.template = "feature_callout"; } originalData.push(clean_looker_object); } return originalData; }