compare -> default; deprecate dailyCost as metric; cleanup

This commit is contained in:
Ryan Vazquez
2020-10-16 09:54:28 -04:00
parent 921d705d51
commit d47e2287b5
18 changed files with 196 additions and 185 deletions
+1 -3
View File
@@ -236,11 +236,9 @@ costInsights:
name: Big Query
icon: search
metrics:
dailyCost:
name: Pied Piper's Daily Cost
compare: DAU
DAU:
name: Daily Active Users
default: true
MSC:
name: Monthly Subscribers
homepage:
@@ -117,10 +117,7 @@ export class ExampleCostInsightsClient implements CostInsightsApi {
return projects;
}
async getMetricData(
metric: string | null,
intervals: string,
): Promise<MetricData> {
async getMetricData(metric: string, intervals: string): Promise<MetricData> {
const aggregation = aggregationFor(
durationOf(intervals),
100_000,
+3 -4
View File
@@ -79,9 +79,9 @@ costInsights:
### Metrics (Optional)
In the `Cost Overview` panel, users can choose from a dropdown of business metrics to see costs as they relate to a metric, such as daily active users. Metrics must be defined as keys on the `metrics` field. A user-friendly name is **required**. Metrics will be provided to the `getDailyCost` and `getProjectCosts` API methods via the `metric` parameter.
In the `Cost Overview` panel, users can choose from a dropdown of business metrics to see costs as they relate to a metric, such as daily active users. Metrics must be defined as keys on the `metrics` field. A user-friendly name is **required**. Metrics will be provided to the `getMetricData` API method via the `metric` parameter.
**Note:** Cost Insights displays daily cost without a metric by default. The dropdown text for this default can be overridden by assigning it a value on the `dailyCost` field.
An optional `default` field can be set to `true` to set the default comparison metric to daily cost in the Cost Overview panel.
```yaml
## ./app-config.yaml
@@ -95,10 +95,9 @@ costInsights:
name: Some Other Cloud Product
icon: data
metrics:
dailyCost:
name: Earth Rotation
metricA:
name: Metric A ## required
default: true
metricB:
name: Metric B
metricC:
@@ -87,7 +87,7 @@ export type CostInsightsApi = {
* @param intervals An ISO 8601 repeating interval string, such as R2/P1M/2020-09-01
* https://en.wikipedia.org/wiki/ISO_8601#Repeating_intervals
*/
getMetricData(metric: string | null, intervals: string): Promise<MetricData>;
getMetricData(metric: string, intervals: string): Promise<MetricData>;
/**
* Get cost aggregations for a particular cloud product and interval timeframe. This includes
@@ -104,10 +104,9 @@ export type CostInsightsApi = {
* @param product The product from the cost-insights configuration in app-config.yaml
* @param group
* @param duration A time duration, such as P1M. See the Duration type for a detailed explanation
* of how the durations are interpreted in Cost Insights.
* of how the durations are interpreted in Cost Insights.
* @param project (optional) The project id from getGroupProjects or query parameters
*/
getProductInsights(
product: string,
group: string,
@@ -40,6 +40,7 @@ const mockMetrics: Metric[] = [
{
kind: 'some-metric',
name: 'Some Metric',
default: false,
},
];
@@ -101,34 +101,26 @@ const CostInsightsPage = () => {
try {
if (pageFilters.group) {
dispatchLoadingInsights(true);
const intervals = intervalsOf(pageFilters.duration);
const [
fetchedProjects,
fetchedAlerts,
fetchedMetricData,
fetchedCosts,
fetchedDailyCost,
] = await Promise.all([
client.getGroupProjects(pageFilters.group),
client.getAlerts(pageFilters.group),
pageFilters.metric
? client.getMetricData(
pageFilters.metric,
intervalsOf(pageFilters.duration),
)
? client.getMetricData(pageFilters.metric, intervals)
: null,
pageFilters.project
? client.getProjectDailyCost(
pageFilters.project,
intervalsOf(pageFilters.duration),
)
: client.getGroupDailyCost(
pageFilters.group,
intervalsOf(pageFilters.duration),
),
? client.getProjectDailyCost(pageFilters.project, intervals)
: client.getGroupDailyCost(pageFilters.group, intervals),
]);
setProjects(fetchedProjects);
setAlerts(fetchedAlerts);
setMetricData(fetchedMetricData);
setDailyCost(fetchedCosts);
setDailyCost(fetchedDailyCost);
} else {
dispatchLoadingNone(loadingActions);
}
@@ -267,7 +259,10 @@ const CostInsightsPage = () => {
<Grid item xs>
<Box px={3} py={6}>
{!!dailyCost.aggregation.length && (
<CostOverviewCard data={[dailyCost, metricData]} />
<CostOverviewCard
dailyCostData={dailyCost}
metricData={metricData}
/>
)}
<WhyCostsMatter />
</Box>
@@ -35,10 +35,14 @@ import {
} from '../../types';
export type CostOverviewCardProps = {
data: [Cost, MetricData | null];
dailyCostData: Cost;
metricData: MetricData | null;
};
const CostOverviewCard = ({ data }: CostOverviewCardProps) => {
const CostOverviewCard = ({
dailyCostData,
metricData,
}: CostOverviewCardProps) => {
const theme = useTheme<CostInsightsTheme>();
const config = useConfig();
const { ScrollAnchor } = useScroll(DefaultNavigation.CostOverviewCard);
@@ -46,10 +50,12 @@ const CostOverviewCard = ({ data }: CostOverviewCardProps) => {
mapFiltersToProps,
);
// There should always be a daily cost metric but a comparison metric is optional.
const dailyCost = findAlways(config.metrics, m => m.kind === null);
const metric = config.metrics.find(m => m.kind === filters.metric);
const comparedChange = data[1] ? getComparedChange(data[0], data[1]) : null;
const metric = filters.metric
? findAlways(config.metrics, m => m.kind === filters.metric)
: null;
const comparedChange = metricData
? getComparedChange(dailyCostData, metricData)
: null;
return (
<Card style={{ position: 'relative' }}>
@@ -62,21 +68,18 @@ const CostOverviewCard = ({ data }: CostOverviewCardProps) => {
<Box my={1} display="flex" flexDirection="column">
<Box display="flex" flexDirection="row">
<Box mr={2}>
<LegendItem
title={`${dailyCost.name} Trend`}
markerColor={theme.palette.blue}
>
{formatPercent(data[0].change.ratio)}
<LegendItem title="Cost Trend" markerColor={theme.palette.blue}>
{formatPercent(dailyCostData.change.ratio)}
</LegendItem>
</Box>
{metric && metric.kind && data[1] && comparedChange && (
{metric && metricData && comparedChange && (
<>
<Box mr={2}>
<LegendItem
title={metric.name}
title={`${metric.name} Trend`}
markerColor={theme.palette.magenta}
>
{formatPercent(data[1].change.ratio)}
{formatPercent(metricData.change.ratio)}
</LegendItem>
</Box>
<LegendItem
@@ -93,9 +96,9 @@ const CostOverviewCard = ({ data }: CostOverviewCardProps) => {
)}
</Box>
<CostOverviewChart
data={data}
name={dailyCost.name}
compare={metric}
dailyCostData={dailyCostData}
metric={metric}
metricData={metricData}
/>
</Box>
<Box display="flex" justifyContent="flex-end" alignItems="center">
@@ -16,7 +16,6 @@
import React from 'react';
import { useTheme } from '@material-ui/core';
import {
AxisDomain,
ComposedChart,
XAxis,
YAxis,
@@ -30,6 +29,7 @@ import {
import {
ChartData,
Cost,
Maybe,
Metric,
MetricData,
CostInsightsTheme,
@@ -40,71 +40,69 @@ import {
} from '../../utils/graphs';
import CostOverviewTooltip from './CostOverviewTooltip';
import { TooltipItemProps } from '../Tooltip';
import { NULL_METRIC } from '../../hooks/useConfig';
import { useCostOverviewStyles as useStyles } from '../../utils/styles';
import { groupByDate, toDataMax, trendFrom } from '../../utils/charts';
import { aggregationSort } from '../../utils/sort';
type CostOverviewChartProps = {
data: [Cost, MetricData | null];
name: string;
compare?: Metric;
metric: Maybe<Metric>;
metricData: Maybe<MetricData>;
dailyCostData: Cost;
responsive?: boolean;
};
const CostOverviewChart = ({
data,
name,
compare,
dailyCostData,
metric,
metricData,
responsive = true,
}: CostOverviewChartProps) => {
const theme = useTheme<CostInsightsTheme>();
const styles = useStyles(theme);
const { dailyCost, metric } = {
const data = {
dailyCost: {
id: NULL_METRIC,
name: name,
format: 'number',
data: data[0],
dataKey: 'dailyCost',
name: `Daily Cost`,
format: 'currency',
data: dailyCostData,
},
metric: {
id: compare?.kind ?? 'Unknown',
name: compare?.name ?? 'Unknown',
format: data[1]?.format ?? 'number',
data: data[1],
dataKey: metric?.kind ?? 'Unknown',
name: metric?.name ?? 'Unknown',
format: metricData?.format ?? 'number',
data: metricData,
},
};
const metricsByDate = metric.data
? metric.data.aggregation.reduce(groupByDate, {})
const metricsByDate = data.metric.data
? data.metric.data.aggregation.reduce(groupByDate, {})
: {};
const chartData: ChartData[] = dailyCost
.data!.aggregation.slice()
const chartData: ChartData[] = data.dailyCost.data.aggregation
.slice()
.sort(aggregationSort)
.map(entry => ({
date: Date.parse(entry.date),
trend: trendFrom(dailyCost.data!.trendline, Date.parse(entry.date)),
trend: trendFrom(data.dailyCost.data.trendline, Date.parse(entry.date)),
dailyCost: entry.amount,
...(metric && metric.data
? { [metric.id]: metricsByDate[`${entry.date}`] }
...(metric && data.metric.data
? { [data.metric.dataKey]: metricsByDate[`${entry.date}`] }
: {}),
}));
const metricDataMax: AxisDomain = metric
? toDataMax(metric.id, chartData)
: 'dataMax';
function tooltipFormatter(payload: TooltipPayload): TooltipItemProps {
return {
label: payload.dataKey === dailyCost.id ? dailyCost.name : metric.name,
label:
payload.dataKey === data.dailyCost.dataKey
? data.dailyCost.name
: data.metric.name,
value:
payload.dataKey === dailyCost.id
? formatGraphValue(payload.value as number, dailyCost.format)
: formatGraphValue(payload.value as number, metric.format),
payload.dataKey === data.dailyCost.dataKey
? formatGraphValue(payload.value as number, data.dailyCost.format)
: formatGraphValue(payload.value as number, data.metric.format),
fill:
payload.dataKey === dailyCost.id
payload.dataKey === data.dailyCost.dataKey
? theme.palette.blue
: theme.palette.magenta,
};
@@ -131,23 +129,23 @@ const CostOverviewChart = ({
tick={{ fill: styles.axis.fill }}
tickFormatter={formatGraphValue}
width={styles.yAxis.width}
yAxisId={dailyCost.id}
yAxisId={data.dailyCost.dataKey}
/>
{metric && (
<YAxis
hide
domain={[() => 0, metricDataMax]}
domain={[() => 0, toDataMax(data.metric.dataKey, chartData)]}
width={styles.yAxis.width}
yAxisId={metric.id}
yAxisId={data.metric.dataKey}
/>
)}
<Area
dataKey={dailyCost.id}
dataKey={data.dailyCost.dataKey}
isAnimationActive={false}
fill={theme.palette.blue}
fillOpacity={0.4}
stroke="none"
yAxisId={dailyCost.id}
yAxisId={data.dailyCost.dataKey}
/>
<Line
activeDot={false}
@@ -157,24 +155,23 @@ const CostOverviewChart = ({
label={false}
strokeWidth={2}
stroke={theme.palette.blue}
yAxisId={dailyCost.id}
yAxisId={data.dailyCost.dataKey}
/>
{metric && (
<Line
activeDot={false}
dataKey={metric.id}
dataKey={data.metric.dataKey}
dot={false}
isAnimationActive={false}
label={false}
strokeWidth={2}
stroke={theme.palette.magenta}
yAxisId={metric.id}
yAxisId={data.metric.dataKey}
/>
)}
<Tooltip
content={
<CostOverviewTooltip
dataKeys={[dailyCost.id, metric.id]}
dataKeys={[data.dailyCost.dataKey, data.metric.dataKey]}
format={tooltipFormatter}
/>
}
@@ -23,7 +23,7 @@ describe('<MetricSelect />', () => {
it('should display a metric', async () => {
const mockProps: MetricSelectProps = {
metric: 'test',
metrics: [{ kind: 'test', name: 'some-name' }],
metrics: [{ kind: 'test', name: 'some-name', default: false }],
onSelect: jest.fn(),
};
const { getByText } = await renderInTestApp(
@@ -32,25 +32,12 @@ describe('<MetricSelect />', () => {
expect(getByText(/some-name/)).toBeInTheDocument();
});
it('should display a null metric', async () => {
const mockProps: MetricSelectProps = {
metric: null,
metrics: [{ kind: null, name: 'billie-nullish' }],
onSelect: jest.fn(),
};
const { getByText } = await renderInTestApp(
<MetricSelect {...mockProps} />,
);
expect(getByText(/billie-nullish/)).toBeInTheDocument();
});
it('should display all metrics', async () => {
const mockProps: MetricSelectProps = {
metric: null,
metrics: [
{ kind: null, name: 'billie-nullish' },
{ kind: 'MAU1M', name: 'Cost Per Million MAU' },
{ kind: 'my-cool-metric', name: 'metric-mcmetric-face' },
{ kind: 'DAU', name: 'Daily Active Users', default: true },
{ kind: 'MSC', name: 'Monthly Subscribers', default: false },
],
onSelect: jest.fn(),
};
@@ -61,11 +48,10 @@ describe('<MetricSelect />', () => {
UserEvent.click(button);
await waitFor(() => getAllByText(/billie-nullish/));
await waitFor(() => getAllByText(/None/));
// The active metric should display in the popver list and in the input
expect(getAllByText(/billie-nullish/).length).toBe(2);
expect(getByText(/Cost Per Million MAU/)).toBeInTheDocument();
expect(getByText(/metric-mcmetric-face/)).toBeInTheDocument();
expect(getByText(/Daily Active Users/)).toBeInTheDocument();
expect(getByText(/Monthly Subscribers/)).toBeInTheDocument();
});
});
@@ -15,10 +15,9 @@
*/
import React from 'react';
import { Select, MenuItem } from '@material-ui/core';
import { Maybe, Metric, findAlways } from '../../types';
import { InputLabel, FormControl, Select, MenuItem } from '@material-ui/core';
import { Maybe, Metric } from '../../types';
import { useSelectStyles as useStyles } from '../../utils/styles';
import { NULL_METRIC } from '../../hooks/useConfig';
export type MetricSelectProps = {
metric: Maybe<string>;
@@ -29,38 +28,37 @@ export type MetricSelectProps = {
const MetricSelect = ({ metric, metrics, onSelect }: MetricSelectProps) => {
const classes = useStyles();
const handleOnChange = (e: React.ChangeEvent<{ value: unknown }>) => {
if (e.target.value === NULL_METRIC) {
function onChange(e: React.ChangeEvent<{ value: unknown }>) {
if (e.target.value === 'none') {
onSelect(null);
} else {
onSelect(e.target.value as string);
}
};
const renderValue = (value: unknown) => {
const kind = (value === NULL_METRIC ? null : value) as Maybe<string>;
const { name } = findAlways(metrics, m => m.kind === kind);
return <b>{name}</b>;
};
}
return (
<Select
className={classes.select}
variant="outlined"
value={metric || NULL_METRIC}
renderValue={renderValue}
onChange={handleOnChange}
>
{metrics.map((m: Metric) => (
<MenuItem
className={classes.menuItem}
key={m.kind || NULL_METRIC}
value={m.kind || NULL_METRIC}
>
{m.name}
<FormControl variant="outlined">
<InputLabel shrink id="metric-select-label">
Compare to:
</InputLabel>
<Select
id="metric-select"
labelId="metric-select-label"
labelWidth={100}
className={classes.select}
value={metric ?? 'none'}
onChange={onChange}
>
<MenuItem className={classes.menuItem} key="none" value="none">
<em>None</em>
</MenuItem>
))}
</Select>
{metrics.map((m: Metric) => (
<MenuItem className={classes.menuItem} key={m.kind} value={m.kind}>
<b>{m.name}</b>
</MenuItem>
))}
</Select>
</FormControl>
);
};
+16 -33
View File
@@ -25,12 +25,10 @@ import { useApi, configApiRef } from '@backstage/core';
import { Config as BackstageConfig } from '@backstage/config';
import { Currency, defaultCurrencies, Product, Icon, Metric } from '../types';
import { getIcon } from '../utils/navigation';
export const NULL_METRIC = 'dailyCost';
export const NULL_METRIC_NAME = 'Daily Cost';
import { validateMetrics } from '../utils/config';
/*
* Config schema 2020-09-28
* Config schema 2020-10-15
*
* costInsights:
* engineerCost: 200000
@@ -42,11 +40,9 @@ export const NULL_METRIC_NAME = 'Daily Cost';
* name: Product B
* icon: data
* metrics:
* dailyCost:
* name: Daily Cost
* compare: metricA
* metricA:
* name: Metric A
* default: true
* metricB:
* name: Metric B
*/
@@ -64,7 +60,7 @@ export const ConfigContext = createContext<ConfigContextProps | undefined>(
);
const defaultState: ConfigContextProps = {
metrics: [{ kind: null, name: NULL_METRIC_NAME }],
metrics: [],
products: [],
icons: [],
engineerCost: 0,
@@ -90,9 +86,9 @@ export const ConfigProvider = ({ children }: { children: ReactNode }) => {
const metrics = c.getOptionalConfig('costInsights.metrics');
if (metrics) {
return metrics.keys().map(key => ({
kind: key === NULL_METRIC ? null : key,
kind: key,
name: metrics.getString(`${key}.name`),
compare: metrics.getOptionalString(`${key}.compare`),
default: metrics.getOptionalBoolean(`${key}.default`) ?? false,
}));
}
@@ -119,23 +115,15 @@ export const ConfigProvider = ({ children }: { children: ReactNode }) => {
const engineerCost = getEngineerCost();
const icons = getIcons();
if (metrics.find((m: Metric) => m.kind === null)) {
setConfig(prevState => ({
...prevState,
metrics,
products,
engineerCost,
icons,
}));
} else {
setConfig(prevState => ({
...prevState,
metrics: [...prevState.metrics, ...metrics],
products,
engineerCost,
icons,
}));
}
validateMetrics(metrics);
setConfig(prevState => ({
...prevState,
metrics,
products,
engineerCost,
icons,
}));
setLoading(false);
}
@@ -154,12 +142,7 @@ export const ConfigProvider = ({ children }: { children: ReactNode }) => {
export function useConfig(): ConfigContextProps {
const config = useContext(ConfigContext);
if (!config) {
assertNever();
}
return config;
return config ? config : assertNever();
}
function assertNever(): never {
@@ -75,9 +75,8 @@ export const FilterProvider = ({ children }: FilterProviderProps) => {
const queryParams = useQueryParams();
const qsRef = useRef('');
const groups = useGroups();
const { products } = useConfig();
const defaultProductFilters = products.map(product => ({
const defaultProductFilters = config.products.map(product => ({
productType: product.kind,
duration: Duration.P1M,
}));
@@ -103,8 +102,8 @@ export const FilterProvider = ({ children }: FilterProviderProps) => {
// TODO: Figure out why pageFilters doesn't get updated by the above when groups are loaded.
useEffect(() => {
const initialState = getInitialPageState(groups, queryParams.pageFilters);
const compared = config.metrics.find(m => m.kind === null)?.compare;
setPageFilters({ ...initialState, metric: compared || null });
const defaultMetric = config.metrics.find(m => m.default);
setPageFilters({ ...initialState, metric: defaultMetric?.kind ?? null });
}, [groups]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
@@ -15,6 +15,7 @@
*/
import { Cost } from './Cost';
import { MetricData } from './MetricData';
import { aggregationSort } from '../utils/sort';
export interface ChangeStatistic {
@@ -51,9 +52,12 @@ export function growthOf(amount: number, ratio: number) {
}
// Used by <CostOverviewCard /> for displaying engineer totals
export function getComparedChange(a: Cost, b: Cost): ChangeStatistic {
const ratio = a.change.ratio - b.change.ratio;
const amount = a.aggregation.slice().sort(aggregationSort)[0].amount;
export function getComparedChange(
dailyCost: Cost,
metricData: MetricData,
): ChangeStatistic {
const ratio = dailyCost.change.ratio - metricData.change.ratio;
const amount = dailyCost.aggregation.slice().sort(aggregationSort)[0].amount;
return {
ratio: ratio,
amount: amount * ratio,
-4
View File
@@ -24,7 +24,3 @@ export interface Cost {
change: ChangeStatistic;
trendline: Trendline;
}
export interface MetricData extends Cost {
format: 'number' | 'currency';
}
+2 -4
View File
@@ -14,10 +14,8 @@
* limitations under the License.
*/
import { Maybe } from '../types';
export type Metric = {
kind: Maybe<string>;
kind: string;
name: string;
compare?: string;
default: boolean;
};
@@ -0,0 +1,25 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DateAggregation } from './DateAggregation';
import { ChangeStatistic } from './ChangeStatistic';
export interface MetricData {
id: string;
format: 'number' | 'currency';
aggregation: DateAggregation[];
change: ChangeStatistic;
}
+1
View File
@@ -27,6 +27,7 @@ export * from './Filters';
export * from './Group';
export * from './Loading';
export * from './Maybe';
export * from './MetricData';
export * from './Metric';
export * from './Product';
export * from './Project';
+32
View File
@@ -0,0 +1,32 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Metric } from '../types';
export function validateMetrics(metrics: Metric[]) {
let defaultMetric = null;
for (const metric of metrics) {
if (metric.default) {
if (defaultMetric) {
throw new Error(
`Cannot set default for multiple metrics: Received: ${defaultMetric.kind} and ${metric.kind}`,
);
} else {
defaultMetric = metric;
}
}
}
}