lib/nimbusRecipe.ts (354 lines of code) (raw):

import { BranchInfo, RecipeInfo, RecipeOrBranchInfo } from "../app/columns.jsx"; import { getAndroidDashboardLink, getSurfaceData, getPreviewLink, getTemplateFromMessage, getDesktopDashboardLink, } from "../lib/messageUtils.ts"; import { getProposedEndDate, MESSAGING_EXPERIMENTS_DEFAULT_FEATURES, _substituteLocalizations, formatDate, } from "../lib/experimentUtils.ts"; import { getExperimentLookerDashboardDate } from "./lookerUtils.ts"; /** * Type aliasing is used here to convert the NimbusExperiment JSON schema for * the v7 api to be used for TypeScript objects. */ const nimbusExperimentV7Schema = require("@mozilla/nimbus-schemas/schemas/NimbusExperimentV7.schema.json"); type NimbusExperiment = typeof nimbusExperimentV7Schema.properties; type DocumentationLink = typeof nimbusExperimentV7Schema.properties.documentationLinks; function isMessagingFeature(featureId: string): boolean { return MESSAGING_EXPERIMENTS_DEFAULT_FEATURES.includes(featureId); } // Get the first messaging feature object in a branch. // XXX should handle the cases where there are none function getFirstMessagingFeature(branch: any): any { const index = branch.features.findIndex((feature: any) => isMessagingFeature(feature.featureId), ); return branch.features[index]; } /** * @returns true if branchInfo has a microsurvey. Currently, we are * doing a heuristic check over the branchInfo id, slug, description, * and userFacingName for the 'survey' substring. */ function _branchInfoHasMicrosurvey(branchInfo: BranchInfo): boolean { if ( branchInfo.id.toLowerCase().includes("survey") || branchInfo.slug.toLowerCase().includes("survey") || (branchInfo.description && branchInfo.description.toLowerCase().includes("survey")) || (branchInfo.userFacingName && branchInfo.userFacingName.toLowerCase().includes("survey")) ) { return true; } return false; } type NimbusRecipeType = { _rawRecipe: NimbusExperiment; _isCompleted: boolean; getRecipeInfo(): RecipeInfo; getRecipeOrBranchInfos(): RecipeOrBranchInfo[]; getBranchInfo(branch: any): BranchInfo; getBranchInfos(): BranchInfo[]; getBranchScreenshotsLink(branchSlug: string): string; usesMessagingFeatures(): boolean; isExpRecipe(): boolean; getBranchRecipeLink(branchSlug: string): string; }; export class NimbusRecipe implements NimbusRecipeType { _rawRecipe; _isCompleted; constructor(recipe: NimbusExperiment, isCompleted: boolean = false) { this._rawRecipe = recipe; this._isCompleted = isCompleted; } getAndroidBranchInfo(branch: any): BranchInfo { let branchInfo: BranchInfo = { product: "Android", id: branch.slug, isBranch: true, // The raw experiment data can be automatically serialized to // the client by NextJS (but classes can't), and any // needed NimbusRecipe class rewrapping can be done there. nimbusExperiment: this._rawRecipe, slug: branch.slug, screenshots: branch.screenshots, description: branch.description, }; // XXX need to handle multi branches const feature = branch.features[0]; switch (feature.featureId) { case "messaging": // console.log("in messaging feature, feature = ", feature); // console.log("feature.value = ", feature.value); if (Object.keys(feature.value).length === 0) { console.warn( "empty feature value, returning error, branch.slug = ", branch.slug, ); return branchInfo; } const message0: any = Object.values(feature.value.messages)[0]; const message0Id: string = Object.keys(feature.value.messages)[0]; branchInfo.id = message0Id; // console.log("message0 = ", message0); const surface = message0.surface; // XXX need to rename template & surface somehow branchInfo.template = surface; branchInfo.surface = getSurfaceData(surface).surface; switch (surface) { case "messages": // XXX I don' think this a real case console.log("in messages surface case"); break; case "survey": break; default: console.warn("unhandled message surface: ", branchInfo.surface); } break; case "juno-onboarding": console.warn(`we don't fully support juno-onboarding messages yet`); break; default: console.warn("default hit"); console.warn("branch.slug = ", branch.slug); console.warn("We don't support feature = ", feature); } const proposedEndDate = getExperimentLookerDashboardDate( branchInfo.nimbusExperiment.startDate, branchInfo.nimbusExperiment.proposedDuration, ); let formattedEndDate; if (branchInfo.nimbusExperiment.endDate) { formattedEndDate = formatDate(branchInfo.nimbusExperiment.endDate, 1); } branchInfo.ctrDashboardLink = getAndroidDashboardLink( branchInfo.template as string, branchInfo.id, undefined, branchInfo.nimbusExperiment.slug, branch.slug, branchInfo.nimbusExperiment.startDate, branchInfo.nimbusExperiment.endDate ? formattedEndDate : proposedEndDate, this._isCompleted, ); console.log("Android Dashboard: ", branchInfo.ctrDashboardLink); return branchInfo; } /** * @returns an array of BranchInfo objects, one per branch in this recipe */ getBranchInfo(branch: any): BranchInfo { switch (this._rawRecipe.appName) { case "fenix": return this.getAndroidBranchInfo(branch); default: return this.getDesktopBranchInfo(branch); } } getDesktopBranchInfo(branch: any): BranchInfo { let branchInfo: BranchInfo = { product: "Desktop", id: branch.slug, isBranch: true, // The raw experiment data can be automatically serialized to // the client by NextJS (but classes can't), and any // needed NimbusRecipe class rewrapping can be done there. nimbusExperiment: this._rawRecipe, slug: branch.slug, screenshots: branch.screenshots, description: branch.description, }; // XXX right now we don't support more than one messaging feature const feature = getFirstMessagingFeature(branch); // XXX in this case we're really passing a feature value. Hmm.... // about:welcome is special and doesn't use the template property, // so we have to assign it directly to treatment branches. The // control branch doesn't have a message, so we don't want to assign // a surface to it. let template; if (feature.featureId === "aboutwelcome" && branch.slug != "control") { // XXX dmose nasty hack to prevent what I'm calling // "non-messaging-aboutwelcome" features from breaking // Skylight completely. Need to talk to Jason and Meg to // understand more details and figure out what to do here... if (Object.keys(feature.value).length <= 1) { branchInfo.template = branch.template = "non-messaging-aboutwelcome"; return branchInfo; } template = "aboutwelcome"; } else if ( feature.featureId === "whatsNewPage" && branch.slug != "control" ) { // XXX whatsNewPage doesn't have a template property so we need to // assign it directly in order for it to display a surface template = "whatsNewPage"; } else { template = getTemplateFromMessage(feature.value); } branch.template = template; branchInfo.template = template; branchInfo.surface = getSurfaceData(template).surface; branchInfo.hasMicrosurvey = _branchInfoHasMicrosurvey(branchInfo); switch (template) { case "aboutwelcome": branchInfo.id = feature.value.id; // Only create a preview object if there's something to preview if (feature.value.hasOwnProperty("screens")) { // featureValue will become the "content" object in a spotlight JSON let spotlightFake = { id: this._rawRecipe.id, template: "spotlight", targeting: true, content: feature.value, }; // Add the modal property to the spotlight to mimic about:welcome spotlightFake.content.modal = "tab"; // The recipe might have a backdrop, but if not, fall back to the default spotlightFake.content.backdrop = feature.value.backdrop || "var(--mr-welcome-background-color) var(--mr-welcome-background-gradient)"; // Localize the recipe if necessary. let localizedWelcome = _substituteLocalizations( spotlightFake, this._rawRecipe.localizations?.[ Object.keys(this._rawRecipe.localizations)[0] ], ); branchInfo.previewLink = getPreviewLink(localizedWelcome); } break; case "feature_callout": // XXX should iterate over all screens // // NOTE: Some branches have incorrect ":treatment-a" attached to the end // of the id, which is breaking the Looker dashboard links // (see https://bugzilla.mozilla.org/show_bug.cgi?id=1902424). // The problem was in the recipe JSON in Experimenter, likely a user error // during experiment creation that involved some cloning or copy/paste. // // XXX consider pulling branch ids from somewhere else that is validated // by Experimenter, to avoid similar user errors in branch ids. branchInfo.id = feature.value.content.screens[0].id.split(":")[0]; // Localize the feature callout if necessary let localizedFeatureCallout = _substituteLocalizations( feature.value, this._rawRecipe.localizations?.[ Object.keys(this._rawRecipe.localizations)[0] ], ); // Use the localized object to generate the previewlink branchInfo.previewLink = getPreviewLink(localizedFeatureCallout); break; case "infobar": branchInfo.id = feature.value.id; // Localize the recipe if necessary. // XXX [Object.keys(recipe.localizations)[0]] accesses the first locale inside the localization object. // We'll probably want to add a dropdown component that allows us to choose a locale from the available ones, to pass to this function. let localizedInfobar = _substituteLocalizations( feature.value.content, this._rawRecipe.localizations?.[ Object.keys(this._rawRecipe.localizations)[0] ], ); branchInfo.previewLink = getPreviewLink(localizedInfobar); break; case "toast_notification": if (!feature.value?.id) { console.warn("value.id, v = ", feature.value); return branchInfo; } branchInfo.id = feature.value.content.tag; break; case "spotlight": branchInfo.id = feature.value.id; // Localize the recipe if necessary. let localizedSpotlight = _substituteLocalizations( feature.value, this._rawRecipe.localizations?.[ Object.keys(this._rawRecipe.localizations)[0] ], ); branchInfo.previewLink = getPreviewLink(localizedSpotlight); break; case "multi": // XXX only does first messages const firstMessage = feature.value.messages[0]; if (!("content" in firstMessage)) { // console.warn( // 'template "multi" first message does not contain content key details not rendered', // ); return branchInfo; } // XXX only does first screen branchInfo.id = firstMessage.content.screens[0].id; // Localize the recipe if necessary. let localizedMulti = _substituteLocalizations( feature.value.messages[0], this._rawRecipe.localizations?.[ Object.keys(this._rawRecipe.localizations)[0] ], ); // XXX assumes previewable message (spotight?) branchInfo.previewLink = getPreviewLink(localizedMulti); break; case "momentsUpdate": console.warn(`we don't fully support moments messages yet`); return branchInfo; default: // console.log("Hit default case, template = ", template); if (!feature.value?.messages) { // console.log("v.messages is null"); // console.log(", feature.value = ", feature.value); return branchInfo; } branchInfo.id = feature.value.messages[0].id; break; } const proposedEndDate = getExperimentLookerDashboardDate( branchInfo.nimbusExperiment.startDate, branchInfo.nimbusExperiment.proposedDuration, ); let formattedEndDate; if (branchInfo.nimbusExperiment.endDate) { formattedEndDate = formatDate(branchInfo.nimbusExperiment.endDate, 1); } branchInfo.ctrDashboardLink = getDesktopDashboardLink( branch.template, branchInfo.id, undefined, branchInfo.nimbusExperiment.slug, branch.slug, branchInfo.nimbusExperiment.startDate, branchInfo.nimbusExperiment.endDate ? formattedEndDate : proposedEndDate, this._isCompleted, ); // if (!feature.value.content) { // console.log("v.content is null"); // console.log("v= ", value) // } // console.log("branchInfo = ") // console.log(branchInfo) return branchInfo; } getBranchInfos(): BranchInfo[] { // console.log(`in gBI for recipe ${recipe.slug}, branches = `) // console.table(recipe.branches) let branchInfos: BranchInfo[] = this._rawRecipe.branches.map( this.getBranchInfo, this, ); return branchInfos; } /** * @returns a RecipeInfo object, for display in the experiments table */ getRecipeInfo(): RecipeInfo { let branchInfos = this.getBranchInfos(); let hasMicrosurvey = branchInfos.some( (branchInfo) => branchInfo.hasMicrosurvey === true, ); if (this._rawRecipe.slug) { hasMicrosurvey = hasMicrosurvey || this._rawRecipe.slug.toLowerCase().includes("survey"); } return { startDate: this._rawRecipe.startDate || null, endDate: this._rawRecipe.endDate || getProposedEndDate( this._rawRecipe.startDate, this._rawRecipe.proposedDuration, ) || null, product: "Desktop", id: this._rawRecipe.slug, segment: "some segment", ctrPercent: 0.5, // get me from BigQuery ctrPercentChange: 2, // get me from BigQuery metrics: "some metrics", experimenterLink: `https://experimenter.services.mozilla.com/nimbus/${this._rawRecipe.slug}`, userFacingName: this._rawRecipe.userFacingName, nimbusExperiment: this._rawRecipe, branches: branchInfos, hasMicrosurvey: hasMicrosurvey, experimentBriefLink: this.getExperimentBriefLink( this._rawRecipe.documentationLinks, ), }; } /** * @returns an array of RecipeInfo and BranchInfo objects for this recipe, * ordered like this: [RecipeInfo, BranchInfo, BranchInfo, BranchInfo, ...] */ getRecipeOrBranchInfos(): RecipeOrBranchInfo[] { let recipeInfo = this.getRecipeInfo(); let branchInfos: BranchInfo[] = this.getBranchInfos(); // console.log("branchInfos[] = ") // console.log(branchInfos) let expAndBranchInfos: RecipeOrBranchInfo[] = []; expAndBranchInfos = ([recipeInfo] as RecipeOrBranchInfo[]).concat( branchInfos, ); // console.log("expAndBranchInfos: ") // console.table(expAndBranchInfos) return expAndBranchInfos; } /** * */ usesMessagingFeatures(): boolean { const featureIds = this._rawRecipe?.featureIds; if (!featureIds) { return false; } return featureIds.some(isMessagingFeature); } /** * Given a branch slug, return a link to the Screenshots section of the * Experimenter page for that branch. */ getBranchScreenshotsLink(branchSlug: string): string { const screenshotsAnchorId = `branch-${encodeURIComponent(branchSlug)}-screenshots`; return `https://experimenter.services.mozilla.com/nimbus/${encodeURIComponent(this._rawRecipe.slug)}/summary#${screenshotsAnchorId}`; } /** * @returns true if this recipe is an experiment recipe not in rollout. */ isExpRecipe() { return !this._rawRecipe.isRollout; } /** * @returns a link to recipe section of the Experimenter page for that branch. */ getBranchRecipeLink(branchSlug: string): string { return `https://experimenter.services.mozilla.com/nimbus/${encodeURIComponent( this._rawRecipe.slug, )}/summary#${branchSlug}`; } /** * @param documentationLinks a list of documentation links provided for this Nimbus recipe * @returns the first documentation link of the experiment brief Google Doc if it exists */ getExperimentBriefLink( documentationLinks: DocumentationLink[] | undefined, ): string | undefined { if (documentationLinks) { const brief = documentationLinks.find( (documentationLink: DocumentationLink) => { return ( documentationLink.title === "DESIGN_DOC" && documentationLink.link.startsWith( "https://docs.google.com/document", ) ); }, ); return brief && brief.link; } } }