import { isNothing, isSome } from "@jet/environment/types/optional"; import { LintedMetricsEvent } from "../../api/models/metrics/metrics"; import * as serverData from "../../foundation/json-parsing/server-data"; import { BuyParameters } from "../../foundation/metrics/buy-parameters"; import { cookiesOf } from "../../foundation/metrics/cookies"; import { MetricsIdentifierType } from "../../foundation/metrics/metrics-identifiers-cache"; import { URL } from "../../foundation/network/urls"; import { reduceSignificantDigits } from "../../foundation/util/math-util"; import * as constants from "./helpers/constants"; import { stripUniqueImpressionIdsIfNecessary } from "./helpers/impressions"; import { iAdDismissAdActionMetricsParameterStringToken, iAdURLLineItemParameterStringToken, iAdURLParameterStringToken, } from "./helpers/models"; import * as searchResultImpressions from "./helpers/search-result-impressions"; import * as searchFocusImpressions from "./helpers/search-focus-impressions"; import * as util from "./helpers/util"; import { MetricsReferralContext } from "./metrics-referral-context"; /** * A type which applies App Store business rules to metrics fields * and generates events which are ready for posting to figaro. */ export class EventLinter { /** * Create an event linter. * * @param options The options which specify various behaviors of the new linter. * This object will be frozen. */ constructor(options) { this._options = Object.freeze(options); } // endsection // section Public Properties /** * Topic to use if an event fields blob does not specify one. */ get defaultTopic() { return this._options.defaultTopic; } // endsection // section Utilities /** * Reduce the accuracy of fields in a blob according to * a given array of rules found in a metrics objectGraph.bag. * * @param eventFields The fields of an event to reduce the accuracy of. * @param rules An array of rules from a metrics objectGraph.bag. */ _reduceFieldAccuracy(eventFields, rules) { for (const rule of rules) { const fieldName = serverData.asString(rule, "fieldName"); if (serverData.isNull(fieldName)) { continue; } const value = serverData.asNumber(eventFields, fieldName); if (serverData.isNull(value)) { continue; } let magnitude = serverData.asNumber(rule, "magnitude"); if (serverData.isNull(magnitude)) { magnitude = 1024 * 1024; } let significantDigits = serverData.asNumber(rule, "significantDigits"); if (serverData.isNull(significantDigits)) { significantDigits = 2; } if (magnitude <= 0.0 || significantDigits < 0.0) { // This is the failure mode from MetricsKit. eventFields[fieldName] = Number.NaN; continue; } const scaledValue = value / magnitude; eventFields[fieldName] = reduceSignificantDigits(scaledValue, significantDigits); } } /** * Returns a new URL by scrubbing any ad fields we've inserted into URLs to pass * ad attribution information between pages. * @param urlString The original URL to be scrubbed * @returns A scrubbed URL. */ _urlScrubbingAdParameters(urlString) { const url = new URL(urlString); url.removeParam(iAdURLParameterStringToken); url.removeParam(iAdURLLineItemParameterStringToken); url.removeParam(iAdDismissAdActionMetricsParameterStringToken); return url.build(); } /** * Returns a new URL by scrubbing everything but the protocol, domain, and port (e.g. host). * @param urlString The original URL to be scrubbed * @returns A scrubbed URL. */ _urlScrubbingExtRefUrl(urlString) { const url = new URL(urlString); url.username = ""; url.password = ""; url.pathname = undefined; url.query = undefined; url.hash = undefined; return url.build(); } // endsection // section Fast Impressions Deresolution _derezFastImpressions(eventFields) { const impressionQueue = serverData.asString(eventFields, "impressionQueue"); const eventVersion = serverData.asNumber(eventFields, "eventVersion"); if (impressionQueue !== "data-metrics-impressions-low-latency") { return; // Only scrub data going to the AP low latency queue. } // Scrub `viewedInfo` of a v4 event. if (eventVersion === 4) { const impressions = serverData.asArrayOrEmpty(eventFields, "impressions"); eventFields["impressions"] = impressions.map((impression) => { if (isNothing(impression)) { return impression; } const viewedInfo = serverData.asArrayOrEmpty(impression, "viewedInfo"); if (viewedInfo.length === 0) { return impression; // V3. No modification. } /** * Metrics: iAd: JS must clear impression start times, and derezz duration to 2 sig figs * - start time to 0 (strange ask - we already send this as part of `impressionTimes`) * - duration to 2 sig fig. */ impression["viewedInfo"] = viewedInfo.map((interval) => { if (isNothing(interval)) { return interval; } const duration = serverData.asNumber(interval, "d"); interval["s"] = 0; if (isSome(duration)) { interval["d"] = reduceSignificantDigits(duration, 2); } return interval; }); return impression; }); } // Scrub `viewedInfoDetailed` of a v5 event. if (eventVersion === 5) { const impressions = serverData.asArrayOrEmpty(eventFields, "impressions"); eventFields["impressions"] = impressions.map((impression) => { if (isNothing(impression)) { return impression; } // Impression items in a low latency impressions event shouldn't have a `cardType` field. delete impression["cardType"]; if (serverData.isNullOrEmpty(serverData.asString(impression, "iAdMetadata")) || serverData.isNullOrEmpty(serverData.asString(impression, "iAdImpressionId"))) { // If the iAdMetadata or iAdImpressionId for this impression on the fast queue is null, // it's an organic result that has added instrumentation as it's in a defined ad slot. // For these items, we have to scrub the adamId for privacy reasons. delete impression["id"]; } // Get the dictionary of values, formatted like so: // {0: [{s: 11636658562756, d: 23281}], 50: [{s: 1636658559350, d: 378}]} const viewedInfoDetailed = serverData.asDictionary(impression, "viewedInfoDetailed"); if (isNothing(viewedInfoDetailed) || serverData.isNullOrEmpty(viewedInfoDetailed)) { return impression; // Nothing to modify. } /** * rdar://89785026 (Chainlink: v5 Impressions ViewedInfoDetailed - Fixes for startTime and duration) * - match scrubbing "s" value and de-rezing "d" value as per above v4 implementation. */ Object.entries(viewedInfoDetailed).forEach(([key, value]) => { // For each key/value pair, grab the array of traditional "viewedInfo". const viewedInfo = serverData.asArrayOrEmpty(value); // Iterate over the array of values, scrubbing the required children. viewedInfoDetailed[key] = viewedInfo.map((interval) => { if (isNothing(interval)) { return interval; } const duration = serverData.asNumber(interval, "d"); interval["s"] = 0; if (isSome(duration)) { interval["d"] = reduceSignificantDigits(duration, 2); } return interval; }); }); // Re-set the modified dictionary on the `impression` object. impression["viewedInfoDetailed"] = viewedInfoDetailed; return impression; }); } } _stripContentRatingImpressionFields(eventFields) { const impressions = serverData.asArrayOrEmpty(eventFields, "impressions"); eventFields["impressions"] = impressions.map((impression) => { if (isSome(impression)) { // "contentRating" and "bundleId" are used by native to // determine the value of "contentRestrictionReasons" (whether a // lockup's offer is disabled due to content restrictions). // However we don't want these fields in the final impression. delete impression["contentRating"]; delete impression["bundleId"]; } return impression; }); } // endsection // section Search Results Page Impressions /** * Decorate `impressions` field for click and impressions events on SRP. */ _decorateSearchResultImpressions(eventFields) { const pageType = serverData.asString(eventFields, "pageType"); const pageId = serverData.asString(eventFields, "pageId"); /** * Only run on SRP. */ const isSearchResultsPage = pageType === "Search" && pageId !== "hints"; if (isSearchResultsPage) { searchResultImpressions.decorateImpressionParentId(eventFields); } } // endsection // section Search Focus Page Impressions /** * Decorate `impressions` field for click and impressions events on SFP. */ _decorateSearchFocusImpressions(eventFields) { const pageType = serverData.asString(eventFields, "pageType"); const pageId = serverData.asString(eventFields, "pageId"); /** * Only run on SFP. */ if (pageType === "SearchFocus" && pageId === "Focus") { searchFocusImpressions.decorateImpressionParentId(eventFields); } } // endsection // section Rules /** * Apply the rules which are universal to all metrics events * to a given metrics fields linter. * * @param eventFields The fields which will be used to construct a built event. * @param topic The topic the built event will be submitted to. */ _decorateAll(objectGraph, eventFields, topic) { var _a, _b, _c; const getBagValue = this._options.bagProvider; // - Metrics base fields const metricsBase = getBagValue("metricsBase", topic); if (!serverData.isNull(metricsBase) && typeof metricsBase === "object") { Object.assign(eventFields, metricsBase); } // - Universal basic fields eventFields["clientBuildType"] = this._options.buildType; eventFields["resourceRevNum"] = this._options.jsVersion; eventFields["xpSendMethod"] = "jet-js"; this._options.buyDecorator.useApp(serverData.asString(eventFields, "app")); // - Universal scrubbing delete eventFields[constants.contextualAdamIdKey]; // - Cookie-based fields const cookies = cookiesOf(serverData.asString(eventFields, "cookie")); for (const cookie of cookies) { if (cookie.key === "xp_ci") { // Always update buy decorator. this._options.buyDecorator.useClientId(cookie.value); break; } } delete eventFields["cookie"]; const clientIdFields = (_b = (_a = objectGraph.metricsIdentifiersCache) === null || _a === void 0 ? void 0 : _a.getMetricsFieldsForTypes([MetricsIdentifierType.client])) !== null && _b !== void 0 ? _b : {}; Object.assign(eventFields, clientIdFields); delete eventFields["clientGeneratedId"]; // - page const pageType = serverData.asString(eventFields, "pageType"); const pageId = serverData.asString(eventFields, "pageId"); if (!serverData.isNull(pageType) && !serverData.isNull(pageId)) { const separator = serverData.asString(getBagValue("compoundSeparator", topic)) || "_"; eventFields["page"] = `${pageType}${separator}${pageId}`; } // - Field value resolution reduction const rules = serverData.asArrayOrEmpty(getBagValue("deResFields", topic)); this._reduceFieldAccuracy(eventFields, rules); // Scrub sensitive urls in event from data that should not leave the device. // At times we insert ad metadata into URLs in order to pass ad attribution information between pages. // Additionally, scrub extRefUrl of everything but the origin. const urlFieldsToScrub = ["pageUrl", "actionUrl", "extRefUrl", "refUrl", "url", "parentPageUrl"]; for (const urlField of urlFieldsToScrub) { const urlString = serverData.asString(eventFields, urlField); if (isSome(urlString) && urlString.length > 0) { eventFields[urlField] = urlField === "extRefUrl" ? this._urlScrubbingExtRefUrl(urlString) : this._urlScrubbingAdParameters(urlString); } } // Where an `overridePageContext` has been provided, use it as the `pageContext` value. const overridePageContext = serverData.asString(eventFields, "overridePageContext"); if (isSome(overridePageContext)) { delete eventFields["overridePageContext"]; eventFields["pageContext"] = overridePageContext; } if (objectGraph.bag.isMetricsUserIdFallbackEnabled) { const existingUserId = serverData.asString(eventFields, "userId"); let metricsUserDSID = null; if (isNothing(existingUserId) || existingUserId.length === 0 || existingUserId.length === EventLinter.clientGeneratedUserIdLength) { metricsUserDSID = (_c = objectGraph.user.dsid) !== null && _c !== void 0 ? _c : null; } if (isSome(metricsUserDSID) && metricsUserDSID.length > 0) { eventFields["dsId"] = metricsUserDSID; } } } _decorateClick(eventFields) { util.adjustGhostHintFieldsForClick(eventFields); // clicks have snapshot impressions field. this._decorateSearchResultImpressions(eventFields); this._decorateSearchFocusImpressions(eventFields); MetricsReferralContext.shared.addReferralDataToEventIfNecessary(eventFields); this._filterBuyParams(eventFields); /// rdar://118948967 (Disable snapshot impressions on iOS 17) const pageType = serverData.asString(eventFields, "pageType"); if (isNothing(pageType) || !pageType.toLowerCase().includes("search")) { delete eventFields["impressions"]; } stripUniqueImpressionIdsIfNecessary(eventFields); } /** * Apply the rules specific to the `impression` event. * * @param objectGraph Current object graph * @param eventFields The fields which will be used to construct a built event. * @returns Whether or not the impressions event is valid and non-empty */ _decorateImpressions(objectGraph, eventFields) { if (serverData.isNullOrEmpty(eventFields["impressions"])) { return false; } this._derezFastImpressions(eventFields); this._decorateSearchResultImpressions(eventFields); this._stripContentRatingImpressionFields(eventFields); const refUrl = serverData.asString(eventFields, "refUrl"); if (isSome(refUrl) && refUrl.length > 0) { eventFields["searchTerm"] = util.searchTermFromRefURL(refUrl); delete eventFields["refUrl"]; } if (objectGraph.client.isVision) { const pageUrl = serverData.asString(eventFields, "pageUrl"); if (isSome(pageUrl) && isNothing(eventFields["searchTerm"])) { const searchTerm = util.searchTermFromProductURL(pageUrl); if (isSome(searchTerm)) { eventFields["searchTerm"] = searchTerm; } } } stripUniqueImpressionIdsIfNecessary(eventFields); // We need the impressionQueue for _derezFastImpressions, so we know which events to derez, but then we need to // remove it from the event // rdar://144333694 ([Ad Platforms][CrystalE][iOS]Seeing 'Impression Queue' as a top level // field for impressions which was removed earlier) delete eventFields["impressionQueue"]; return true; } /** * Apply the rules specific to the `media` event. * * @param eventFields The fields which will be used to construct a built event. */ _decorateMedia(eventFields) { const position = serverData.asNumber(eventFields, "position"); if (!serverData.isNull(position)) { eventFields["position"] = Math.round(position); } } /** * Apply the rules specific to the `buyparams` . * * @param eventFields The fields which will be used to construct a built event. */ _filterBuyParams(eventFields) { const buyParamsString = serverData.asString(eventFields, "actionDetails.buyParams"); if (isSome(buyParamsString) && buyParamsString.length > 0) { const buyParams = new BuyParameters(buyParamsString); const disallowedFields = ["ownerDsid"]; disallowedFields.forEach((key) => { buyParams.set(key, null, null); }); if (isSome(eventFields["actionDetails"])) { eventFields["actionDetails"]["buyParams"] = buyParams.toString(); } } } /** * Apply the rules specific to the `page` event. * * @param objectGraph Current object graph * @param eventFields The fields which will be used to construct a built event. */ _decoratePage(objectGraph, eventFields) { const page = serverData.asString(eventFields, "page"); if (!serverData.isNull(page)) { eventFields["pageHistory"] = this._options.buyDecorator.getPageHistoryFor(page); } MetricsReferralContext.shared.setReferralDataForProductPageExtensionIfNecessary(eventFields); MetricsReferralContext.shared.beginReferralContextForPageIfNecessary(eventFields); MetricsReferralContext.shared.addReferralDataToEventIfNecessary(eventFields); // Make sure to add the referral data before checking for valid refUrl const refUrl = serverData.asString(eventFields, "refUrl"); if (!serverData.isNull(refUrl)) { const refApp = util.extractSiriRefAppFromRefURL(refUrl); const searchTerm = util.searchTermFromRefURL(refUrl); if (refApp !== null && refApp.length > 0) { eventFields["refApp"] = refApp; } if (searchTerm !== null && searchTerm.length > 0) { eventFields["searchTerm"] = searchTerm; } } if (objectGraph.client.isVision) { const pageUrl = serverData.asString(eventFields, "pageUrl"); if (isSome(pageUrl) && isNothing(eventFields["searchTerm"])) { const searchTerm = util.searchTermFromProductURL(pageUrl); if (isSome(searchTerm)) { eventFields["searchTerm"] = searchTerm; } } } } /** * Apply the rules specific to the `pageChange` event. The pageChange should have the same treatment * as page event. * * @param eventFields The fields which will be used to construct a built event. */ _decoratePageChange(objectGraph, eventFields) { this._decoratePage(objectGraph, eventFields); } /** * Apply the rules specific to the `search` event. * * @param objectGraph Current object graph * @param eventFields The fields which will be used to construct a built event. */ _decorateSearch(eventFields) { eventFields["eventVersion"] = 3; util.adjustGhostHintFieldsForSearch(eventFields); } /** * Apply the rules specific to the `pageExit` event. * * @param eventFields The fields which will be used to construct a built event. */ _decoratePageExit(eventFields) { MetricsReferralContext.shared.endReferralContextIfNecessaryForPageEvent(eventFields); } // endsection // section Filter _filterExtraneous(eventFields) { util.removeExtraGhostHintFields(eventFields); MetricsReferralContext.shared.removeReferralContextInfoFromMetricsEvent(eventFields); } // endsection // section Building Events /** * Create a metrics event by applying the business rules of App Store to a given fields blob. * * @param eventFields The fields to use to construct an event. * @returns A built event ready for posting. */ makeEvent(objectGraph, eventFields) { var _a, _b; if (preprocessor.GAMES_TARGET) { // Until we get further with Privacy/Legal, the decision has been made to disable instrumentation // for Game Overlay. the pre-consent fields provider is only active for Game Overlay. Other options // for more fine-tuned control (_gameCenterPreConsent and _crossUsePreConsent) have been added to // provide JS flexibility. const isPreConsentFieldsProviderEnabled = eventFields["_isPreConsentFieldsProviderEnabled"]; const gameCenterPreConsent = eventFields["_gameCenterPreConsent"]; const crossUsePreConsent = eventFields["_crossUsePreConsent"]; if (isPreConsentFieldsProviderEnabled === true || gameCenterPreConsent === true || crossUsePreConsent === true) { return new LintedMetricsEvent({}); } delete eventFields["_isPreConsentFieldsProviderEnabled"]; delete eventFields["_gameCenterPreConsent"]; delete eventFields["_crossUsePreConsent"]; } const eventType = serverData.asString(eventFields, "eventType"); if (this._options.isLoggingEnabled) { objectGraph.console.log(`Building event for topic: ${eventType}`); } // rdar://135738684 (TLF: Off Store Lockups Removal) // Currently all events from App Store Components are suppressed. // Delete all fields to leave an empty event. const app = eventFields["app"]; if (app === "com.apple.appstorecomponentsd") { return new LintedMetricsEvent({}); } const topic = serverData.asString(eventFields, "topic") || this._options.defaultTopic; this._decorateAll(objectGraph, eventFields, topic); // rdar://148554411 (Metrics: Disable PII collection from U13 accounts) let isUnderThirteenAccount; if (preprocessor.GAMES_TARGET && objectGraph.props.enabled("157263806-add-playerBridge-askGlobal")) { isUnderThirteenAccount = (_b = (_a = objectGraph.player) === null || _a === void 0 ? void 0 : _a.isUnderThirteen) !== null && _b !== void 0 ? _b : false; } else { isUnderThirteenAccount = objectGraph.user.isUnderThirteen; } if (isUnderThirteenAccount) { delete eventFields["dsId"]; delete eventFields["userId"]; delete eventFields["canonicalAccountIdentifierOverride"]; } const extRefUrl = eventFields["extRefUrl"]; if (extRefUrl && extRefUrl === "") { delete eventFields["extRefUrl"]; } switch (eventType) { case "click": this._decorateClick(eventFields); break; case "exit": break; case "impressions": const isValidEvent = this._decorateImpressions(objectGraph, eventFields); if (!isValidEvent) { // We want to filter out empty impressions events, // and passing an empty event will contractually drop it from the metrics recorder return new LintedMetricsEvent({}); } break; case "media": this._decorateMedia(eventFields); break; case "page": this._decoratePage(objectGraph, eventFields); break; case "pageChange": this._decoratePageChange(objectGraph, eventFields); break; case "pageExit": this._decoratePageExit(eventFields); break; case "search": this._decorateSearch(eventFields); break; default: break; } this._filterExtraneous(eventFields); if (objectGraph.bag.metricsIdMigrationEnabled) { util.removeDSIDFields(eventFields); } return new LintedMetricsEvent(eventFields); } } /** * The length of a client generated user ID. */ EventLinter.clientGeneratedUserIdLength = 24; /** * Key whose value indicates if event fields contain iAd data. */ EventLinter.hasIAdData = "hasiAdData"; //# sourceMappingURL=event-linter.js.map