Fix #156: Add extension UI probes
* Add `open_popup`, `open_external_page`, `add_product`, `delete_product` and `undo_delete_product` probes (See ./docs/METRICS.md for their specifications). * Update `badge_type` `extra_key` description in METRICS.md to add a new enum value of 'unknown' in the case that the badge text is unrecognized, and a badge type cannot be determined. * Update `addProductFromExtracted` action creator to return a thunk, which returns a random UUID (v4) for use as a product key in telemetry. This allows us to differentiate one product from another for a particular user. This is NOT a universal product id across all users. * Add `tabId` URL searchParam to browserAction popup URL when an extracted product is found on the current page in order to obtain the `badge_type` `extra_key` value for the `open_popup` event.
This commit is contained in:
Родитель
c5a10fd001
Коммит
dbf9036fc8
|
@ -137,7 +137,7 @@ Below is a sample ping for the `badge_toolbar_button` and `visit_supported_site`
|
|||
|
||||
`extra_keys` are keys in the [optional `extra` object field](https://firefox-source-docs.mozilla.org/toolkit/components/telemetry/telemetry/collection/events.html#serialization-format) for telemetry events. All `extra_keys` and their values are strings.
|
||||
|
||||
- `'badge_type'`: Indicates what, if any, badge was present on the browserAction toolbar button. One of 'add', 'price_alert', or 'none'.
|
||||
- `'badge_type'`: Indicates what, if any, badge was present on the browserAction toolbar button. One of 'add', 'price_alert', or 'none'. A value of 'unknown' is possible if the badge text is unrecognized.
|
||||
- `'extraction_id'`: A unique identifier to associate an extraction attempt to an extraction completion event for a given page.
|
||||
- `'is_bg_update'`: 'true' if the extraction is associated with a background price check; otherwise 'false'.
|
||||
- `method`: The extraction method that was successful, if any. One of: 'fathom', 'fallback' or 'neither'. A value of 'neither' means that extraction failed.
|
||||
|
|
|
@ -2524,7 +2524,7 @@
|
|||
"color-support": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
|
||||
"integrity": "sha1-k4NDeaHMmgxh+C9S8NBDIiUb1aI=",
|
||||
"integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==",
|
||||
"dev": true
|
||||
},
|
||||
"colors": {
|
||||
|
|
|
@ -58,6 +58,7 @@
|
|||
"react-dom": "16.4.1",
|
||||
"react-redux": "5.0.7",
|
||||
"redux": "4.0.0",
|
||||
"redux-thunk": "2.3.0"
|
||||
"redux-thunk": "2.3.0",
|
||||
"uuid": "3.3.2"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -40,6 +40,7 @@ export async function handleExtractedProductData(message, sender) {
|
|||
// Update the toolbar icon's URL with the current page's product if we can
|
||||
const url = new URL(await config.get('browserActionUrl'));
|
||||
url.searchParams.set('extractedProduct', JSON.stringify(extractedProduct));
|
||||
url.searchParams.set('tabId', JSON.stringify(tabId));
|
||||
|
||||
// Update the toolbar popup while it is open with the current page's product
|
||||
if (sender.tab.active) {
|
||||
|
|
|
@ -12,12 +12,14 @@ import store from 'commerce/state';
|
|||
import {
|
||||
deactivateAlert,
|
||||
getActivePriceAlerts,
|
||||
getLatestPriceForProduct,
|
||||
getOldestPriceForProduct,
|
||||
getPrice,
|
||||
getPriceAlertForPrice,
|
||||
showPriceAlert,
|
||||
} from 'commerce/state/prices';
|
||||
import {getProduct} from 'commerce/state/products';
|
||||
import {recordEvent} from 'commerce/background/telemetry';
|
||||
|
||||
/**
|
||||
* Update the extension UI based on the current state of active price alerts.
|
||||
|
@ -75,6 +77,16 @@ export function handleNotificationClicked(notificationId) {
|
|||
const product = getProduct(state, alert.productId);
|
||||
browser.tabs.create({url: product.url});
|
||||
|
||||
const latestPrice = getLatestPriceForProduct(state, product.id);
|
||||
const originalPrice = getOldestPriceForProduct(state, product.id);
|
||||
recordEvent('open_external_page', 'ui_element', null, {
|
||||
element: 'system_notification',
|
||||
price: latestPrice.amount.getAmount().toString(),
|
||||
price_alert: alert.active.toString(),
|
||||
price_orig: originalPrice.amount.getAmount().toString(),
|
||||
product_key: product.key,
|
||||
});
|
||||
|
||||
// Mark the alert as inactive if necessary, since it was followed.
|
||||
if (alert.active) {
|
||||
store.dispatch(deactivateAlert(alert));
|
||||
|
|
|
@ -8,6 +8,8 @@
|
|||
*/
|
||||
|
||||
import {shouldCollectTelemetry} from 'commerce/privacy';
|
||||
import store from 'commerce/state';
|
||||
import {getAllProducts} from 'commerce/state/products';
|
||||
|
||||
const CATEGORY = 'extension.price_alerts';
|
||||
|
||||
|
@ -167,6 +169,25 @@ export async function recordEvent(method, object, value, extra) {
|
|||
method,
|
||||
object,
|
||||
value,
|
||||
extra,
|
||||
{
|
||||
...extra,
|
||||
// Add extra_keys that are appended to every event
|
||||
tracked_prods: getAllProducts(store.getState()).length.toString(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function getBadgeType(tabId) {
|
||||
const badgeText = await browser.browserAction.getBadgeText(tabId ? {tabId} : {});
|
||||
switch (true) {
|
||||
case (badgeText === ''):
|
||||
return 'none';
|
||||
case (badgeText === '✚'):
|
||||
return 'add';
|
||||
case (/\d+/.test(badgeText)):
|
||||
return 'price_alert';
|
||||
default:
|
||||
console.warn(`Unexpected badge text ${badgeText}.`); // eslint-disable-line no-console
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,6 +12,7 @@ import EmptyOnboarding from 'commerce/browser_action/components/EmptyOnboarding'
|
|||
import TrackedProductList from 'commerce/browser_action/components/TrackedProductList';
|
||||
import {extractedProductShape, getAllProducts, productShape} from 'commerce/state/products';
|
||||
import * as syncActions from 'commerce/state/sync';
|
||||
import {recordEvent, getBadgeType} from 'commerce/background/telemetry';
|
||||
|
||||
import 'commerce/browser_action/components/BrowserActionApp.css';
|
||||
|
||||
|
@ -32,6 +33,7 @@ export default class BrowserActionApp extends React.Component {
|
|||
static propTypes = {
|
||||
// Direct props
|
||||
extractedProduct: extractedProductShape, // Product detected on the current page, if any
|
||||
tabId: pt.number,
|
||||
|
||||
// State props
|
||||
products: pt.arrayOf(productShape).isRequired,
|
||||
|
@ -42,6 +44,7 @@ export default class BrowserActionApp extends React.Component {
|
|||
|
||||
static defaultProps = {
|
||||
extractedProduct: null,
|
||||
tabId: null,
|
||||
}
|
||||
|
||||
constructor(props) {
|
||||
|
@ -51,9 +54,14 @@ export default class BrowserActionApp extends React.Component {
|
|||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
async componentDidMount() {
|
||||
this.props.loadStateFromStorage();
|
||||
|
||||
// Record 'open_popup' event
|
||||
recordEvent('open_popup', 'toolbar_button', null, {
|
||||
badge_type: await getBadgeType(this.props.tabId),
|
||||
});
|
||||
|
||||
browser.runtime.onMessage.addListener((message) => {
|
||||
if (message.subject === 'extracted-product') {
|
||||
this.setState({extractedProduct: message.extractedProduct});
|
||||
|
@ -66,6 +74,7 @@ export default class BrowserActionApp extends React.Component {
|
|||
*/
|
||||
async handleClickHelp() {
|
||||
browser.tabs.create({url: await config.get('supportUrl')});
|
||||
recordEvent('open_external_page', 'ui_element', null, {element: 'help_button'});
|
||||
window.close();
|
||||
}
|
||||
|
||||
|
@ -74,6 +83,7 @@ export default class BrowserActionApp extends React.Component {
|
|||
*/
|
||||
async handleClickFeedback() {
|
||||
browser.tabs.create({url: await config.get('feedbackUrl')});
|
||||
recordEvent('open_external_page', 'ui_element', null, {element: 'feedback_button'});
|
||||
window.close();
|
||||
}
|
||||
|
||||
|
|
|
@ -8,6 +8,7 @@ import React from 'react';
|
|||
import TrackProductButton from 'commerce/browser_action/components/TrackProductButton';
|
||||
import config from 'commerce/config';
|
||||
import {extractedProductShape} from 'commerce/state/products';
|
||||
import {recordEvent} from 'commerce/background/telemetry';
|
||||
|
||||
import 'commerce/browser_action/components/EmptyOnboarding.css';
|
||||
|
||||
|
@ -49,6 +50,8 @@ export default class EmptyOnboarding extends React.Component {
|
|||
if (event.target.href) {
|
||||
event.preventDefault();
|
||||
browser.tabs.create({url: event.target.href});
|
||||
const element = `${event.target.id}_link`;
|
||||
recordEvent('open_external_page', 'ui_element', null, {element});
|
||||
window.close();
|
||||
}
|
||||
}
|
||||
|
@ -69,14 +72,14 @@ export default class EmptyOnboarding extends React.Component {
|
|||
*/}
|
||||
<p className="description">
|
||||
Add products you want to buy from
|
||||
{' '}<a href="https://www.amazon.com">Amazon</a>,
|
||||
{' '}<a href="https://www.bestbuy.com/">Best Buy</a>,
|
||||
{' '}<a href="https://www.ebay.com/">eBay</a>,
|
||||
{' '}<a href="https://www.homedepot.com/">Home Depot</a>, and
|
||||
{' '}<a href="https://www.walmart.com/">Walmart</a>
|
||||
{' '}<a id="amazon" href="https://www.amazon.com">Amazon</a>,
|
||||
{' '}<a id="best_buy" href="https://www.bestbuy.com/">Best Buy</a>,
|
||||
{' '}<a id="ebay" href="https://www.ebay.com/">eBay</a>,
|
||||
{' '}<a id="home_depot" href="https://www.homedepot.com/">Home Depot</a>, and
|
||||
{' '}<a id="walmart" href="https://www.walmart.com/">Walmart</a>
|
||||
{' '}to your Price Watcher list, and Firefox will notify you if the price drops.
|
||||
</p>
|
||||
<a href={learnMoreHref} className="learn-more">Learn More</a>
|
||||
<a id="learn_more" href={learnMoreHref} className="learn-more">Learn More</a>
|
||||
<TrackProductButton className="button" extractedProduct={extractedProduct} />
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -16,6 +16,7 @@ import {
|
|||
} from 'commerce/state/prices';
|
||||
import {productShape} from 'commerce/state/products';
|
||||
import * as productActions from 'commerce/state/products';
|
||||
import {recordEvent} from 'commerce/background/telemetry';
|
||||
|
||||
import 'commerce/browser_action/components/ProductCard.css';
|
||||
|
||||
|
@ -37,6 +38,7 @@ export default class ProductCard extends React.Component {
|
|||
static propTypes = {
|
||||
// Direct props
|
||||
product: productShape.isRequired,
|
||||
index: pt.number.isRequired,
|
||||
|
||||
// State props
|
||||
latestPrice: priceWrapperShape.isRequired,
|
||||
|
@ -57,16 +59,32 @@ export default class ProductCard extends React.Component {
|
|||
*/
|
||||
handleClick() {
|
||||
browser.tabs.create({url: this.props.product.url});
|
||||
this.recordClickEvent('open_external_page', 'ui_element', {element: 'product_card'});
|
||||
window.close();
|
||||
}
|
||||
|
||||
handleClickDelete(event) {
|
||||
event.stopPropagation();
|
||||
this.props.setDeletionFlag(this.props.product.id, true);
|
||||
this.recordClickEvent('delete_product', 'delete_button');
|
||||
}
|
||||
|
||||
handleClickUndo() {
|
||||
this.props.setDeletionFlag(this.props.product.id, false);
|
||||
this.recordClickEvent('undo_delete_product', 'undo_button');
|
||||
}
|
||||
|
||||
recordClickEvent(method, object, extra = {}) {
|
||||
const {activePriceAlert, latestPrice, originalPrice, product, index} = this.props;
|
||||
recordEvent(method, object, null, {
|
||||
...extra,
|
||||
price: latestPrice.amount.getAmount().toString(),
|
||||
// activePriceAlert is undefined if this product has never had a price alert
|
||||
price_alert: activePriceAlert ? activePriceAlert.active.toString() : 'false',
|
||||
price_orig: originalPrice.amount.getAmount().toString(),
|
||||
product_index: index.toString(),
|
||||
product_key: product.key,
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
|
|
|
@ -9,6 +9,7 @@ import {connect} from 'react-redux';
|
|||
|
||||
import * as productActions from 'commerce/state/products';
|
||||
import {extractedProductShape, isProductTracked} from 'commerce/state/products';
|
||||
import {recordEvent} from 'commerce/background/telemetry';
|
||||
|
||||
/**
|
||||
* Button that tracks a product extracted from the current page when clicked.
|
||||
|
@ -44,7 +45,12 @@ export default class TrackProductButton extends React.Component {
|
|||
* Track the current tab's product when the track button is clicked.
|
||||
*/
|
||||
handleClickTrack() {
|
||||
this.props.addProductFromExtracted(this.props.extractedProduct);
|
||||
const {extractedProduct} = this.props;
|
||||
const uuid = this.props.addProductFromExtracted(extractedProduct);
|
||||
recordEvent('add_product', 'add_button', null, {
|
||||
price: extractedProduct.price.toString(),
|
||||
product_key: uuid,
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
|
|
|
@ -44,9 +44,9 @@ export default class TrackedProductList extends React.Component {
|
|||
<React.Fragment>
|
||||
<TrackProductButton className="menu-item" extractedProduct={extractedProduct} />
|
||||
<ul className="product-list">
|
||||
{sortedProducts.map(product => (
|
||||
{sortedProducts.map((product, index) => (
|
||||
<li className="product-list-item" key={product.id}>
|
||||
<ProductCard product={product} />
|
||||
<ProductCard product={product} index={index} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
|
|
@ -20,6 +20,12 @@ if (extractedProductJSON) {
|
|||
appProps.extractedProduct = JSON.parse(extractedProductJSON);
|
||||
}
|
||||
|
||||
// Pull tabId if present; only available via the url when there's a currently-viewed product
|
||||
const tabIdJSON = url.searchParams.get('tabId');
|
||||
if (tabIdJSON) {
|
||||
appProps.tabId = JSON.parse(tabIdJSON);
|
||||
}
|
||||
|
||||
ReactDOM.render(
|
||||
<Provider store={store}>
|
||||
<BrowserActionApp {...appProps} />
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
*/
|
||||
|
||||
import pt from 'prop-types';
|
||||
import uuidv4 from 'uuid/v4';
|
||||
|
||||
// Types
|
||||
|
||||
|
@ -23,6 +24,7 @@ export const productShape = pt.shape({
|
|||
image: pt.string.isRequired,
|
||||
isDeleted: pt.bool.isRequired,
|
||||
vendorFaviconUrl: pt.string.isRequired,
|
||||
key: pt.string.isRequired,
|
||||
});
|
||||
|
||||
/**
|
||||
|
@ -88,14 +90,22 @@ export default function reducer(state = initialState(), action) {
|
|||
// Action Creators
|
||||
|
||||
/**
|
||||
* Add a new product to the store.
|
||||
* Add a new product to the store, adding an additional key with a random UUID (v4) value
|
||||
* to help track this product for this user in telemetry.
|
||||
* @param {ExtractedProduct} data
|
||||
*/
|
||||
export function addProductFromExtracted(data) {
|
||||
return {
|
||||
return ((dispatch) => {
|
||||
const uuid = uuidv4();
|
||||
dispatch({
|
||||
type: ADD_PRODUCT,
|
||||
extractedProductData: data,
|
||||
};
|
||||
extractedProductData: {
|
||||
...data,
|
||||
key: uuid,
|
||||
},
|
||||
});
|
||||
return uuid;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -188,5 +198,6 @@ export function getProductFromExtracted(data) {
|
|||
image: data.image,
|
||||
vendorFaviconUrl: data.vendorFaviconUrl || '',
|
||||
isDeleted: false,
|
||||
key: data.key,
|
||||
};
|
||||
}
|
||||
|
|
Загрузка…
Ссылка в новой задаче