-
Notifications
You must be signed in to change notification settings - Fork 1.4k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
638ef77
commit 5b115ce
Showing
8 changed files
with
511 additions
and
283 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
485 changes: 208 additions & 277 deletions
485
...s/experiments/ExperimentView/DeltaViz.tsx → ...es/experiments/MetricsView/DeltaChart.tsx
Large diffs are not rendered by default.
Oops, something went wrong.
178 changes: 178 additions & 0 deletions
178
frontend/src/scenes/experiments/MetricsView/MetricsView.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,178 @@ | ||
import { IconPlus } from '@posthog/icons' | ||
import { LemonButton } from '@posthog/lemon-ui' | ||
import { useActions, useValues } from 'kea' | ||
import { IconAreaChart } from 'lib/lemon-ui/icons' | ||
|
||
import { experimentLogic, getDefaultFunnelsMetric } from '../experimentLogic' | ||
import { MAX_PRIMARY_METRICS } from './const' | ||
import { DeltaChart } from './DeltaChart' | ||
|
||
// Helper function to find nice round numbers for ticks | ||
export function getNiceTickValues(maxAbsValue: number): number[] { | ||
// Round up maxAbsValue to ensure we cover all values | ||
maxAbsValue = Math.ceil(maxAbsValue * 10) / 10 | ||
|
||
const magnitude = Math.floor(Math.log10(maxAbsValue)) | ||
const power = Math.pow(10, magnitude) | ||
|
||
let baseUnit | ||
const normalizedMax = maxAbsValue / power | ||
if (normalizedMax <= 1) { | ||
baseUnit = 0.2 * power | ||
} else if (normalizedMax <= 2) { | ||
baseUnit = 0.5 * power | ||
} else if (normalizedMax <= 5) { | ||
baseUnit = 1 * power | ||
} else { | ||
baseUnit = 2 * power | ||
} | ||
|
||
// Calculate how many baseUnits we need to exceed maxAbsValue | ||
const unitsNeeded = Math.ceil(maxAbsValue / baseUnit) | ||
|
||
// Determine appropriate number of decimal places based on magnitude | ||
const decimalPlaces = Math.max(0, -magnitude + 1) | ||
|
||
const ticks: number[] = [] | ||
for (let i = -unitsNeeded; i <= unitsNeeded; i++) { | ||
// Round each tick value to avoid floating point precision issues | ||
const tickValue = Number((baseUnit * i).toFixed(decimalPlaces)) | ||
ticks.push(tickValue) | ||
} | ||
return ticks | ||
} | ||
|
||
function AddMetric({ | ||
metrics, | ||
setExperiment, | ||
openPrimaryMetricModal, | ||
}: { | ||
metrics: any[] | ||
setExperiment: (payload: { metrics: any[] }) => void | ||
openPrimaryMetricModal: (index: number) => void | ||
}): JSX.Element { | ||
return ( | ||
<LemonButton | ||
icon={<IconPlus />} | ||
type="secondary" | ||
size="small" | ||
onClick={() => { | ||
const newMetrics = [...metrics, getDefaultFunnelsMetric()] | ||
setExperiment({ | ||
metrics: newMetrics, | ||
}) | ||
openPrimaryMetricModal(newMetrics.length - 1) | ||
}} | ||
disabledReason={ | ||
metrics.length >= MAX_PRIMARY_METRICS | ||
? `You can only add up to ${MAX_PRIMARY_METRICS} primary metrics.` | ||
: undefined | ||
} | ||
> | ||
Add metric | ||
</LemonButton> | ||
) | ||
} | ||
|
||
export function MetricsView(): JSX.Element { | ||
const { experiment, getMetricType, metricResults, primaryMetricsResultErrors, credibleIntervalForVariant } = | ||
useValues(experimentLogic) | ||
const { setExperiment, openPrimaryMetricModal } = useActions(experimentLogic) | ||
|
||
const variants = experiment.parameters.feature_flag_variants | ||
const metrics = experiment.metrics || [] | ||
|
||
// Calculate the maximum absolute value across ALL metrics | ||
const maxAbsValue = Math.max( | ||
...metrics.flatMap((_, metricIndex) => { | ||
const result = metricResults?.[metricIndex] | ||
if (!result) { | ||
return [] | ||
} | ||
return variants.flatMap((variant) => { | ||
const interval = credibleIntervalForVariant(result, variant.key, getMetricType(metricIndex)) | ||
return interval ? [Math.abs(interval[0] / 100), Math.abs(interval[1] / 100)] : [] | ||
}) | ||
}) | ||
) | ||
|
||
const padding = Math.max(maxAbsValue * 0.05, 0.02) | ||
const chartBound = maxAbsValue + padding | ||
|
||
const commonTickValues = getNiceTickValues(chartBound) | ||
|
||
return ( | ||
<div className="mb-4"> | ||
<div className="flex"> | ||
<div className="w-1/2 pt-5"> | ||
<div className="inline-flex space-x-2 mb-0"> | ||
<h2 className="mb-1 font-semibold text-lg">Primary metrics</h2> | ||
</div> | ||
</div> | ||
|
||
<div className="w-1/2 flex flex-col justify-end"> | ||
<div className="ml-auto"> | ||
<div className="mb-2 mt-4 justify-end"> | ||
<AddMetric | ||
metrics={metrics} | ||
setExperiment={setExperiment} | ||
openPrimaryMetricModal={openPrimaryMetricModal} | ||
/> | ||
</div> | ||
</div> | ||
</div> | ||
</div> | ||
{metrics.length > 0 ? ( | ||
<div className="w-full overflow-x-auto"> | ||
<div className="min-w-[800px]"> | ||
{metrics.map((metric, metricIndex) => { | ||
const result = metricResults?.[metricIndex] | ||
const isFirstMetric = metricIndex === 0 | ||
|
||
return ( | ||
<div | ||
key={metricIndex} | ||
className={`w-full border border-border bg-light ${ | ||
metrics.length === 1 | ||
? 'rounded' | ||
: isFirstMetric | ||
? 'rounded-t' | ||
: metricIndex === metrics.length - 1 | ||
? 'rounded-b' | ||
: '' | ||
}`} | ||
> | ||
<DeltaChart | ||
result={result} | ||
error={primaryMetricsResultErrors?.[metricIndex]} | ||
variants={variants} | ||
metricType={getMetricType(metricIndex)} | ||
metricIndex={metricIndex} | ||
isFirstMetric={isFirstMetric} | ||
metric={metric} | ||
tickValues={commonTickValues} | ||
chartBound={chartBound} | ||
/> | ||
</div> | ||
) | ||
})} | ||
</div> | ||
</div> | ||
) : ( | ||
<div className="border rounded bg-bg-light pt-6 pb-8 text-muted mt-2"> | ||
<div className="flex flex-col items-center mx-auto space-y-3"> | ||
<IconAreaChart fontSize="30" /> | ||
<div className="text-sm text-center text-balance"> | ||
Add up to {MAX_PRIMARY_METRICS} primary metrics to monitor side effects of your experiment. | ||
</div> | ||
<AddMetric | ||
metrics={metrics} | ||
setExperiment={setExperiment} | ||
openPrimaryMetricModal={openPrimaryMetricModal} | ||
/> | ||
</div> | ||
</div> | ||
)} | ||
</div> | ||
) | ||
} |
99 changes: 99 additions & 0 deletions
99
frontend/src/scenes/experiments/MetricsView/NoResultEmptyState.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,99 @@ | ||
import { IconArchive } from '@posthog/icons' | ||
import { IconCheck, IconX } from '@posthog/icons' | ||
|
||
export function NoResultEmptyState({ error }: { error: any }): JSX.Element { | ||
if (!error) { | ||
return <></> | ||
} | ||
|
||
type ErrorCode = 'no-events' | 'no-flag-info' | 'no-control-variant' | 'no-test-variant' | ||
|
||
const { statusCode } = error | ||
|
||
function ChecklistItem({ errorCode, value }: { errorCode: ErrorCode; value: boolean }): JSX.Element { | ||
const failureText = { | ||
'no-events': 'Metric events not received', | ||
'no-flag-info': 'Feature flag information not present on the events', | ||
'no-control-variant': 'Events with the control variant not received', | ||
'no-test-variant': 'Events with at least one test variant not received', | ||
} | ||
|
||
const successText = { | ||
'no-events': 'Experiment events have been received', | ||
'no-flag-info': 'Feature flag information is present on the events', | ||
'no-control-variant': 'Events with the control variant received', | ||
'no-test-variant': 'Events with at least one test variant received', | ||
} | ||
|
||
return ( | ||
<div className="flex items-center space-x-2"> | ||
{value === false ? ( | ||
<span className="flex items-center space-x-2"> | ||
<IconCheck className="text-success" fontSize={16} /> | ||
<span className="text-muted">{successText[errorCode]}</span> | ||
</span> | ||
) : ( | ||
<span className="flex items-center space-x-2"> | ||
<IconX className="text-danger" fontSize={16} /> | ||
<span>{failureText[errorCode]}</span> | ||
</span> | ||
)} | ||
</div> | ||
) | ||
} | ||
|
||
// Validation errors return 400 and are rendered as a checklist | ||
if (statusCode === 400) { | ||
let parsedDetail: Record<ErrorCode, boolean> | ||
try { | ||
parsedDetail = JSON.parse(error.detail) | ||
} catch (error) { | ||
return ( | ||
<div className="border rounded bg-bg-light p-4"> | ||
<div className="font-semibold leading-tight text-base text-current"> | ||
Experiment results could not be calculated | ||
</div> | ||
<div className="mt-2">{error}</div> | ||
</div> | ||
) | ||
} | ||
|
||
const checklistItems = [] | ||
for (const [errorCode, value] of Object.entries(parsedDetail)) { | ||
checklistItems.push(<ChecklistItem key={errorCode} errorCode={errorCode as ErrorCode} value={value} />) | ||
} | ||
|
||
return <div>{checklistItems}</div> | ||
} | ||
|
||
if (statusCode === 504) { | ||
return ( | ||
<div> | ||
<div className="border rounded bg-bg-light py-10"> | ||
<div className="flex flex-col items-center mx-auto text-muted space-y-2"> | ||
<IconArchive className="text-4xl text-secondary-3000" /> | ||
<h2 className="text-xl font-semibold leading-tight">Experiment results timed out</h2> | ||
<div className="text-sm text-center text-balance"> | ||
This may occur when the experiment has a large amount of data or is particularly complex. We | ||
are actively working on fixing this. In the meantime, please try refreshing the experiment | ||
to retrieve the results. | ||
</div> | ||
</div> | ||
</div> | ||
</div> | ||
) | ||
} | ||
|
||
// Other unexpected errors | ||
return ( | ||
<div> | ||
<div className="border rounded bg-bg-light py-10"> | ||
<div className="flex flex-col items-center mx-auto text-muted space-y-2"> | ||
<IconArchive className="text-4xl text-secondary-3000" /> | ||
<h2 className="text-xl font-semibold leading-tight">Experiment results could not be calculated</h2> | ||
<div className="text-sm text-center text-balance">{error.detail}</div> | ||
</div> | ||
</div> | ||
</div> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,20 @@ | ||
export const MAX_PRIMARY_METRICS = 10 | ||
|
||
export const BAR_HEIGHT = 8 | ||
export const BAR_PADDING = 10 | ||
export const TICK_PANEL_HEIGHT = 20 | ||
export const VIEW_BOX_WIDTH = 800 | ||
export const HORIZONTAL_PADDING = 20 | ||
export const CONVERSION_RATE_RECT_WIDTH = 2 | ||
export const TICK_FONT_SIZE = 9 | ||
|
||
export const COLORS = { | ||
BOUNDARY_LINES: '#d0d0d0', | ||
ZERO_LINE: '#666666', | ||
BAR_NEGATIVE: '#F44435', | ||
BAR_BEST: '#4DAF4F', | ||
BAR_DEFAULT: '#d9d9d9', | ||
BAR_CONTROL: 'rgba(217, 217, 217, 0.4)', | ||
BAR_MIDDLE_POINT: 'black', | ||
BAR_MIDDLE_POINT_CONTROL: 'rgba(0, 0, 0, 0.4)', | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters