Skip to content

Commit

Permalink
wip
Browse files Browse the repository at this point in the history
  • Loading branch information
jurajmajerik committed Dec 14, 2024
1 parent 638ef77 commit 5b115ce
Show file tree
Hide file tree
Showing 8 changed files with 511 additions and 283 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { WebExperimentImplementationDetails } from 'scenes/experiments/WebExperi

import { ExperimentImplementationDetails } from '../ExperimentImplementationDetails'
import { experimentLogic } from '../experimentLogic'
import { MetricsView } from '../MetricsView/MetricsView'
import {
ExperimentLoadingAnimation,
LoadingState,
Expand Down Expand Up @@ -49,6 +50,7 @@ const ResultsTab = (): JSX.Element => {
)}
</>
)}
<MetricsView />
<SecondaryMetricsTable experimentId={experiment.id} />
</div>
)
Expand Down
5 changes: 1 addition & 4 deletions frontend/src/scenes/experiments/ExperimentView/Results.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,17 @@
import '../Experiment.scss'

import { useValues } from 'kea'
import { FEATURE_FLAGS } from 'lib/constants'

import { experimentLogic } from '../experimentLogic'
import { ResultsHeader, ResultsQuery } from './components'
import { DeltaViz } from './DeltaViz'
import { SummaryTable } from './SummaryTable'

export function Results(): JSX.Element {
const { experimentResults, featureFlags } = useValues(experimentLogic)
const { experimentResults } = useValues(experimentLogic)

return (
<div>
<ResultsHeader />
{featureFlags[FEATURE_FLAGS.EXPERIMENTS_MULTIPLE_METRICS] && <DeltaViz />}
<SummaryTable />
<ResultsQuery targetResults={experimentResults} showTable={true} />
</div>
Expand Down

Large diffs are not rendered by default.

178 changes: 178 additions & 0 deletions frontend/src/scenes/experiments/MetricsView/MetricsView.tsx
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 frontend/src/scenes/experiments/MetricsView/NoResultEmptyState.tsx
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>
)
}
20 changes: 20 additions & 0 deletions frontend/src/scenes/experiments/MetricsView/const.tsx
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)',
}
3 changes: 2 additions & 1 deletion frontend/src/scenes/experiments/experimentLogic.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,7 @@ export const experimentLogic = kea<experimentLogicType>([
minimum_detectable_effect: minimumDetectableEffect,
},
})
actions.closePrimaryMetricModal()
},
updateExperimentCollectionGoal: async () => {
const { recommendedRunningTime, recommendedSampleSize, minimumDetectableEffect } = values
Expand Down Expand Up @@ -677,7 +678,7 @@ export const experimentLogic = kea<experimentLogicType>([
}
},
closePrimaryMetricModal: () => {
// actions.loadExperiment()
actions.loadExperiment()
},
resetRunningExperiment: async () => {
actions.updateExperiment({ start_date: null, end_date: null, archived: false })
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/scenes/experiments/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { EntityType, FeatureFlagFilters, InsightType } from '~/types'

import { getNiceTickValues } from './ExperimentView/DeltaViz'
import { getNiceTickValues } from './MetricsView/MetricsView'
import { getMinimumDetectableEffect, transformFiltersForWinningVariant } from './utils'

describe('utils', () => {
Expand Down

0 comments on commit 5b115ce

Please sign in to comment.