Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 83 additions & 4 deletions frontend/common/services/useExperiment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,17 @@ import Utils from 'common/utils/utils'
import transformCorePaging from 'common/transformCorePaging'

export const experimentService = service
.enhanceEndpoints({ addTagTypes: ['Experiment'] })
.enhanceEndpoints({ addTagTypes: ['Experiment', 'ExperimentExposures'] })
.injectEndpoints({
endpoints: (builder) => ({
completeExperiment: builder.mutation<
Res['experiment'],
Req['experimentAction']
>({
invalidatesTags: [{ id: 'LIST', type: 'Experiment' }],
invalidatesTags: (_res, _err, { experimentId }) => [
{ id: experimentId, type: 'Experiment' },
{ id: 'LIST', type: 'Experiment' },
],
query: ({ environmentId, experimentId }) => ({
method: 'POST',
url: `environments/${environmentId}/experiments/${experimentId}/complete/`,
Expand All @@ -36,6 +39,28 @@ export const experimentService = service
url: `environments/${environmentId}/experiments/${experimentId}/`,
}),
}),
getExperiment: builder.query<Res['experiment'], Req['getExperiment']>({
providesTags: (_res, _err, { experimentId }) => [
{ id: experimentId, type: 'Experiment' },
],
query: ({ environmentId, experimentId }) => ({
url: `environments/${environmentId}/experiments/${experimentId}/`,
}),
}),
getExperimentExposures: builder.query<
Res['experimentExposures'] | null,
Req['getExperimentExposures']
>({
providesTags: (_res, _err, { experimentId }) => [
{ id: experimentId, type: 'ExperimentExposures' },
],
query: ({ environmentId, experimentId }) => ({
url: `environments/${environmentId}/experiments/${experimentId}/exposures/`,
}),
transformResponse: (res: {
exposures: Res['experimentExposures'] | null
}) => res.exposures,
}),
getExperiments: builder.query<Res['experiments'], Req['getExperiments']>({
providesTags: [{ id: 'LIST', type: 'Experiment' }],
query: ({ environmentId, ...rest }) => ({
Expand All @@ -49,30 +74,84 @@ export const experimentService = service
Res['experiment'],
Req['experimentAction']
>({
invalidatesTags: [{ id: 'LIST', type: 'Experiment' }],
invalidatesTags: (_res, _err, { experimentId }) => [
{ id: experimentId, type: 'Experiment' },
{ id: 'LIST', type: 'Experiment' },
],
query: ({ environmentId, experimentId }) => ({
method: 'POST',
url: `environments/${environmentId}/experiments/${experimentId}/pause/`,
}),
}),
refreshExperimentExposures: builder.mutation<
Res['experimentExposures'],
Req['refreshExperimentExposures']
>({
invalidatesTags: (_res, _err, { experimentId }) => [
{ id: experimentId, type: 'ExperimentExposures' },
],
queryFn: async (
{ environmentId, experimentId },
_api,
_extraOptions,
baseQuery,
) => {
const result = await baseQuery({
method: 'POST',
url: `environments/${environmentId}/experiments/${experimentId}/exposures/refresh/`,
})
if (result.error) {
const retryAfter =
result.meta?.response?.headers?.get('Retry-After')
return {
error: {
...result.error,
retryAfter: retryAfter ? parseInt(retryAfter, 10) : null,
},
}
}
return { data: result.data as Res['experimentExposures'] }
},
}),
startExperiment: builder.mutation<
Res['experiment'],
Req['experimentAction']
>({
invalidatesTags: [{ id: 'LIST', type: 'Experiment' }],
invalidatesTags: (_res, _err, { experimentId }) => [
{ id: experimentId, type: 'Experiment' },
{ id: 'LIST', type: 'Experiment' },
],
query: ({ environmentId, experimentId }) => ({
method: 'POST',
url: `environments/${environmentId}/experiments/${experimentId}/start/`,
}),
}),
updateExperiment: builder.mutation<
Res['experiment'],
Req['updateExperiment']
>({
invalidatesTags: (_res, _err, { experimentId }) => [
{ id: experimentId, type: 'Experiment' },
{ id: 'LIST', type: 'Experiment' },
],
query: ({ body, environmentId, experimentId }) => ({
body,
method: 'PATCH',
url: `environments/${environmentId}/experiments/${experimentId}/`,
}),
}),
}),
})

export const {
useCompleteExperimentMutation,
useCreateExperimentMutation,
useDeleteExperimentMutation,
useGetExperimentExposuresQuery,
useGetExperimentQuery,
useGetExperimentsQuery,
usePauseExperimentMutation,
useRefreshExperimentExposuresMutation,
useStartExperimentMutation,
useUpdateExperimentMutation,
} = experimentService
8 changes: 8 additions & 0 deletions frontend/common/types/requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1043,7 +1043,15 @@ export type Req = {
}
}
experimentAction: { environmentId: string; experimentId: number }
updateExperiment: {
environmentId: string
experimentId: number
body: { hypothesis?: string }
}
deleteExperiment: { environmentId: string; experimentId: number }
getExperiment: { environmentId: string; experimentId: number }
getExperimentExposures: { environmentId: string; experimentId: number }
refreshExperimentExposures: { environmentId: string; experimentId: number }
getMetrics: PagedRequest<{
environmentId: string
q?: string
Expand Down
84 changes: 69 additions & 15 deletions frontend/common/types/responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -611,21 +611,6 @@ export type Metric = {
updated_at: string
}

export type ExpectedDirection =
| 'increase'
| 'decrease'
| 'not_increase'
| 'not_decrease'

export type ExperimentMetric = {
id: number
metric: number
metric_name: string
aggregation: MetricAggregation
expected_direction: ExpectedDirection
created_at: string
}

export type ExperimentFeature = {
id: number
name: string
Expand All @@ -647,6 +632,73 @@ export type Experiment = {
ended_at: string | null
}

export type ExpectedDirection =
| 'increase'
| 'decrease'
| 'not_increase'
| 'not_decrease'

// Join object returned on the experiment-detail `metrics` array
// (api/experimentation ExperimentMetricSerializer).
export type ExperimentMetric = {
id: number
metric: number
metric_name: string
aggregation: MetricAggregation
expected_direction: ExpectedDirection
created_at: string
}

// --- Exposures (live) — mirrors api/experimentation dataclasses ---
export type ExposureGranularity = 'hour' | 'day'

export type ExposuresTimeseriesPoint = {
bucket: string
new_identities: Record<string, number>
}

export type ExposuresTimeseries = {
granularity: ExposureGranularity
points: ExposuresTimeseriesPoint[]
}

export type ExposuresSummary = {
excluded_identities: number
timeseries: ExposuresTimeseries
}

export type ExperimentExposures = {
as_of: string | null
last_error_at: string | null
refresh_requested_at: string | null
payload: ExposuresSummary | null
}

// --- Bayesian results (defined now, consumed when the endpoint ships) ---
export type VariantStats = {
n: number
sum: number
sum_squares: number
}

export type Inference = {
lift: number
ci_low: number
ci_high: number
chance_to_win: number
}

export type BayesianMetricResult = {
metric_id: number
variants: Record<string, VariantStats>
inference: Record<string, Inference | null>
}

export type BayesianResultsSummary = {
srm_p_value: number | null
metrics: BayesianMetricResult[]
}

export enum TagStrategy {
INTERSECTION = 'INTERSECTION',
UNION = 'UNION',
Expand Down Expand Up @@ -1427,6 +1479,8 @@ export type Res = {
status_counts?: ExperimentStatusCounts
}
experiment: Experiment
experimentExposures: ExperimentExposures
experimentBayesianResults: BayesianResultsSummary
metric: Metric
metrics: PagedResponse<Metric>
multivariateOption: MultivariateOption
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@
font-weight: var(--font-weight-regular);
padding: 2px 8px;
border-radius: var(--radius-sm);
background: var(--color-surface-emphasis);
background: var(--color-surface-muted);
color: var(--color-text-secondary);
}

Expand Down
11 changes: 8 additions & 3 deletions frontend/web/components/base/grid/ContentCard/ContentCard.scss
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
.content-card {
display: flex;
flex-direction: column;
gap: 24px;
gap: 16px;
padding: 24px;

&--compact {
gap: 8px;
padding: 16px;
}
background: var(--color-surface-subtle);
border: 1px solid var(--color-border-default);
border-radius: var(--radius-lg);
Expand Down Expand Up @@ -30,8 +35,8 @@

&__title {
font-size: var(--font-body-size, 0.875rem);
font-weight: var(--font-weight-bold, 700);
color: var(--color-text-default);
font-weight: var(--font-weight-regular, 400);
color: var(--color-text-secondary);
margin: 0;
}
}
12 changes: 10 additions & 2 deletions frontend/web/components/base/grid/ContentCard/ContentCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,31 @@ type ContentCardProps = {
description?: ReactNode
action?: ReactNode
className?: string
compact?: boolean
children: ReactNode
}

const ContentCard: FC<ContentCardProps> = ({
action,
children,
className,
compact,
description,
title,
}) => {
return (
<div className={cn('content-card', className)}>
<div
className={cn(
'content-card',
compact && 'content-card--compact',
className,
)}
>
{(title || action || description) && (
<div className='content-card__heading'>
{(title || action) && (
<div className='content-card__header'>
{title && <h3 className='content-card__title'>{title}</h3>}
{title && <span className='content-card__title'>{title}</span>}
{action}
</div>
)}
Expand Down
16 changes: 3 additions & 13 deletions frontend/web/components/charts/LineChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,35 +17,25 @@ type LineChartProps = {
data: ChartDataPoint[]
series: string[]
colorMap: Record<string, string>
height?: number
xAxisInterval?: number
/**
* Render recharts' built-in `<Legend />` below the chart. Default `false` —
* most consumers already expose a coloured filter UI (tags / MultiSelect)
* that serves the same purpose, so a second legend is redundant and can
* display raw dataKeys (e.g. numeric env IDs) that are meaningless to users.
*/
showLegend?: boolean
/**
* Optional dataKey → display name map, threaded through to the tooltip (and
* the legend when enabled). Use this when dataKeys are opaque identifiers
* (e.g. numeric env ids) that need a human-readable label on display.
*/
seriesLabels?: Record<string, string>
/** Render vertical grid lines (one per x tick). Default `true`. */
verticalGrid?: boolean
}

const LineChart: FC<LineChartProps> = ({
colorMap,
data,
height = 400,
series,
seriesLabels,
showLegend = false,
verticalGrid = true,
xAxisInterval = 0,
}) => {
return (
<ResponsiveContainer height={400} width='100%'>
<ResponsiveContainer height={height} width='100%'>
<RawLineChart data={data}>
<CartesianGrid
strokeDasharray='3 5'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,14 @@
}

&__row {
cursor: pointer;
transition: background var(--duration-fast) var(--easing-standard);

&:hover {
background: var(--color-surface-hover);
&--clickable {
cursor: pointer;

&:hover {
background: var(--color-surface-subtle);
}
}
}

Expand Down
Loading
Loading