1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-08-27 13:49:10 +02:00

Merge branch 'main' into feat/impact-metrics-grid

This commit is contained in:
Tymoteusz Czech 2025-07-01 15:02:20 +02:00
commit 400d3ce2a2
No known key found for this signature in database
GPG Key ID: 133555230D88D75F
24 changed files with 739 additions and 620 deletions

258
.github/workflows/ai-flag-cleanup-pr.yml vendored Normal file
View File

@ -0,0 +1,258 @@
name: AI flag cleanup PR
on:
workflow_call:
inputs:
issue-number:
description: "Flag completed issue number"
required: true
type: number
model:
description: "Model to use"
required: true
type: string
api_key_env_name:
description: "The name of the API key environment variable. For example, OPENAI_API_KEY, ANTHROPIC_API_KEY, etc. See more info: https://aider.chat/docs/llms.html"
required: true
type: string
base-branch:
description: "Base branch to create PR against (e.g. main)"
required: false
type: string
default: ${{ github.event.repository.default_branch }}
chat-timeout:
description: "Timeout for flag cleanup, in minutes"
required: false
type: number
default: 10
secrets:
api_key_env_value:
description: "The API key"
required: true
permissions:
pull-requests: write
contents: write
issues: read
jobs:
create-pull-request:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.ref }}
- name: Create a new branch
uses: actions/github-script@v7
id: create_branch
with:
script: |
const kebabCase = (str) => {
return str
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '') // Remove invalid characters
.replace(/\s+/g, '-') // Replace spaces with dashes
.replace(/^-+|-+$/g, ''); // Remove leading/trailing dashes
};
const fixBranchUrl = (url) => url
.replace(/\/git\/commits/, '/commit')
.replace(/api.github.com\/repos/, 'github.com');
// New branch should be based on the base-branch, so we need to get its SHA
const baseBranch = await github.rest.repos.getBranch({
owner: context.repo.owner,
repo: context.repo.repo,
branch: '${{ inputs.base-branch }}'
});
const { repo, owner } = context.repo;
const branchName = 'feature/aider-' + kebabCase(context.payload.issue.title);
const refName = `refs/heads/${branchName}`
const refShortName = `heads/${branchName}`
// Get existing ref if exists
const existingRef = await github.rest.git.getRef({
owner,
repo,
ref: refShortName
}).catch(() => null);
if (existingRef) {
try {
// If there's a branch for this ref, return the ref
await github.rest.repos.getBranch({
owner,
repo,
branch: branchName
});
console.log(`Branch ${branchName} already exists with SHA ${existingRef.data.object.sha}`);
console.log(`Branch URL: ${fixBranchUrl(existingRef.data.object.url)}`);
return { ref: existingRef.data.ref }
} catch (e) {
console.error(e);
// State recovery: If there's a ref but no branch, delete the ref and create a new branch
// This can happen if the branch was deleted manually. The ref will still exist.
console.log(`Branch ${branchName} doesn't exist, deleting ref ${refShortName}`);
await github.rest.git.deleteRef({
owner,
repo,
ref: refShortName
});
}
}
// Create branch
const result = await github.rest.git.createRef({
owner,
repo,
ref: refName,
sha: baseBranch.data.commit.sha
});
console.log(`Created branch ${branchName} with SHA ${result.data.object.sha}`);
console.log(`Branch URL: ${fixBranchUrl(result.data.object.url)}`);
return { ref: result.data.ref }
- name: Get issue
uses: actions/github-script@v7
id: get_issue
with:
script: |
console.log('Fetching issue #${{ inputs.issue-number }}')
const { repo, owner } = context.repo;
const result = await github.rest.issues.get({
owner,
repo,
issue_number: ${{ inputs.issue-number }}
});
console.log(`Fetched issue #${result.data.number}: ${result.data.title}`)
return {
title: result.data.title.replace(/"/g, "'").replace(/`/g, '\\`'),
body: result.data.body.replace(/"/g, "'").replace(/`/g, '\\`'),
};
- name: Extract flag name
id: extract_flag
run: |
TITLE="${{ fromJson(steps.get_issue.outputs.result).title }}"
if [[ "$TITLE" =~ Flag[[:space:]]([a-zA-Z0-9_-]+)[[:space:]]marked ]]; then
echo "flag-name=${BASH_REMATCH[1]}" >> $GITHUB_OUTPUT
else
echo "❌ Could not extract flag name from title: $TITLE"
exit 1
fi
- name: Install ripgrep
run: sudo apt-get update && sudo apt-get install -y ripgrep
- name: Find files using the flag
id: find_files
run: |
FLAG="${{ steps.extract_flag.outputs.flag-name }}"
FILES=$(rg -0 -l "$FLAG" . | xargs -0 -I{} printf '"%s" ' "{}")
if [[ -z "$FILES" ]]; then
echo "❌ No files found using flag '$FLAG'."
exit 1
fi
echo "file_args=$FILES" >> "$GITHUB_OUTPUT"
- name: Create prompt
uses: actions/github-script@v7
id: create_prompt
with:
result-encoding: string
script: |
const body = `${{ fromJson(steps.get_issue.outputs.result).body }}`;
return `Apply all necessary changes to clean up this feature flag based on the issue description below.
After making the changes, provide a Markdown summary of what was changed, written for a developer reviewing the PR. These changes should be under a "AI Flag Cleanup Summary" section.
Explain:
- What was removed
- What was kept
- Why these changes were made
Write a natural summary in proper Markdown. Keep it clear, focused, and readable.
--- Issue Description ---
${body}`;
- name: Apply cleanup with Aider
uses: mirrajabi/aider-github-action@v1.1.0
timeout-minutes: ${{ inputs.chat-timeout }}
with:
branch: ${{ fromJson(steps.create_branch.outputs.result).ref }}
model: ${{ inputs.model }}
aider_args: '--yes ${{ steps.find_files.outputs.file_args }} --message "${{ steps.create_prompt.outputs.result }}"'
api_key_env_name: ${{ inputs.api_key_env_name }}
api_key_env_value: ${{ secrets.api_key_env_value }}
- name: Extract summary from Aider output
id: extract_summary
run: |
SUMMARY=$(awk '/AI Flag Cleanup Summary/ {found=1} found' .aider.chat.history.md)
echo "summary<<EOF" >> $GITHUB_OUTPUT
echo "$SUMMARY" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Create Pull Request
uses: actions/github-script@v7
with:
script: |
const { repo, owner } = context.repo;
const branchRef = '${{ fromJson(steps.create_branch.outputs.result).ref }}'
const flagName = '${{ steps.extract_flag.outputs.flag-name }}';
// If PR already exists, return it
const pulls = await github.rest.pulls.list({
owner,
repo,
state: 'open',
per_page: 100
});
const existingPR = pulls.data.find((pr) => pr.head.ref === branchRef);
if (existingPR) {
console.log(`PR #${existingPR.number} already exists: ${existingPR.html_url}`);
return existingPR;
}
const newPR = await github.rest.pulls.create({
title: `[AI] ${flagName} flag cleanup`,
owner,
repo,
head: branchRef,
base: 'refs/heads/${{ inputs.base-branch }}',
body: [
`This PR cleans up the ${flagName} flag. These changes were automatically generated by AI and should be reviewed carefully.`,
'',
`Fixes #${{ inputs.issue-number }}`,
'',
`${{ steps.extract_summary.outputs.summary }}`
].join('\n')
});
github.rest.issues.addLabels({
owner,
repo,
issue_number: newPR.data.number,
labels: ['unleash-ai-flag-cleanup']
});
console.log(`Created PR #${newPR.data.number}: ${newPR.data.html_url}`);
- name: Upload aider chat history
uses: actions/upload-artifact@v4
with:
name: aider-chat-output
path: ".aider.chat.history.md"

View File

@ -1,22 +1,24 @@
name: AI flag cleanup
on:
issues:
types: [labeled]
permissions:
pull-requests: write
contents: write
issues: read
workflow_dispatch:
inputs:
issue-number:
description: 'Flag completed issue number'
required: true
type: number
jobs:
flag-cleanup:
uses: mirrajabi/aider-github-workflows/.github/workflows/aider-issue-to-pr.yml@v1.0.0
if: github.event.label.name == 'unleash-flag-completed'
if: |
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'issues' && github.event.label.name == 'unleash-flag-completed')
uses: ./.github/workflows/ai-flag-cleanup-pr.yml
with:
issue-number: ${{ github.event.issue.number }}
base-branch: ${{ github.event.repository.default_branch }}
chat-timeout: 10
api_key_env_name: GEMINI_API_KEY
issue-number: ${{ github.event.issue.number || inputs.issue-number }}
model: gemini
api_key_env_name: GEMINI_API_KEY
secrets:
api_key_env_value: ${{ secrets.GEMINI_API_KEY }}

View File

@ -1 +0,0 @@
This section has been moved to a separate repository: https://github.com/Unleash/unleash-examples

View File

@ -5,6 +5,7 @@ import { formatDateYMDHMS } from 'utils/formatDate';
import { Link } from 'react-router-dom';
import { styled } from '@mui/material';
import type { EventSchema } from 'openapi';
import { useUiFlag } from 'hooks/useUiFlag';
interface IEventCardProps {
entry: EventSchema;
@ -72,6 +73,7 @@ export const StyledCodeSection = styled('div')(({ theme }) => ({
const EventCard = ({ entry }: IEventCardProps) => {
const { locationSettings } = useLocationSettings();
const eventGroupingEnabled = useUiFlag('eventGrouping');
const createdAtFormatted = formatDateYMDHMS(
entry.createdAt,
@ -138,6 +140,26 @@ const EventCard = ({ entry }: IEventCardProps) => {
</>
}
/>
<ConditionallyRender
condition={
eventGroupingEnabled &&
Boolean(entry.data?.changeRequestId)
}
show={
<>
<StyledDefinitionTerm>
Change request id:
</StyledDefinitionTerm>
<dd>
<Link
to={`/projects/${entry.project}/change-requests/${entry.data?.changeRequestId}`}
>
{String(entry.data?.changeRequestId)}
</Link>
</dd>
</>
}
/>
</dl>
<ConditionallyRender
condition={Boolean(entry.data || entry.preData)}

View File

@ -10,8 +10,9 @@ import {
Box,
Typography,
Alert,
styled,
} from '@mui/material';
import { ImpactMetricsControls } from './ImpactMetricsControls.tsx';
import { ImpactMetricsControls } from './ImpactMetricsControls/ImpactMetricsControls.tsx';
import {
LineChart,
NotEnoughData,
@ -25,6 +26,31 @@ import { useChartData } from './hooks/useChartData.ts';
import type { ChartConfig } from './types.ts';
import type { ImpactMetricsSeries } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata';
export const StyledConfigPanel = styled(Box)(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(3),
[theme.breakpoints.down('lg')]: {
flex: 'none',
},
[theme.breakpoints.up('lg')]: {
flex: '0 0 400px',
},
}));
export const StyledPreviewPanel = styled(Box)(({ theme }) => ({
flex: 1,
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(2),
[theme.breakpoints.down('lg')]: {
minHeight: '300px',
},
[theme.breakpoints.up('lg')]: {
minHeight: '400px',
},
}));
export interface ChartConfigModalProps {
open: boolean;
onClose: () => void;
@ -182,15 +208,7 @@ export const ChartConfigModal: FC<ChartConfigModalProps> = ({
height: '100%',
})}
>
{/* Configuration Panel */}
<Box
sx={(theme) => ({
flex: { xs: 'none', lg: '0 0 400px' },
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(3),
})}
>
<StyledConfigPanel>
<TextField
label='Chart Title (optional)'
value={title}
@ -213,18 +231,10 @@ export const ChartConfigModal: FC<ChartConfigModalProps> = ({
onLabelsChange={setSelectedLabels}
availableLabels={currentAvailableLabels}
/>
</Box>
</StyledConfigPanel>
{/* Preview Panel */}
<Box
sx={(theme) => ({
flex: 1,
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(2),
minHeight: { xs: '300px', lg: '400px' },
})}
>
<StyledPreviewPanel>
<Typography variant='h6' color='text.secondary'>
Preview
</Typography>
@ -316,7 +326,7 @@ export const ChartConfigModal: FC<ChartConfigModalProps> = ({
cover={cover}
/>
</StyledChartContainer>
</Box>
</StyledPreviewPanel>
</Box>
</DialogContent>
<DialogActions>

View File

@ -0,0 +1,66 @@
import type { FC } from 'react';
import { Box, Typography } from '@mui/material';
import type { ImpactMetricsSeries } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata';
import type { ImpactMetricsLabels } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData';
import { SeriesSelector } from './components/SeriesSelector.tsx';
import { RangeSelector, type TimeRange } from './components/RangeSelector.tsx';
import { BeginAtZeroToggle } from './components/BeginAtZeroToggle.tsx';
import { LabelsFilter } from './components/LabelsFilter.tsx';
export type ImpactMetricsControlsProps = {
selectedSeries: string;
onSeriesChange: (series: string) => void;
selectedRange: TimeRange;
onRangeChange: (range: TimeRange) => void;
beginAtZero: boolean;
onBeginAtZeroChange: (beginAtZero: boolean) => void;
metricSeries: (ImpactMetricsSeries & { name: string })[];
loading?: boolean;
selectedLabels: Record<string, string[]>;
onLabelsChange: (labels: Record<string, string[]>) => void;
availableLabels?: ImpactMetricsLabels;
};
export const ImpactMetricsControls: FC<ImpactMetricsControlsProps> = (
props,
) => (
<Box
sx={(theme) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(3),
maxWidth: 400,
})}
>
<Typography variant='body2' color='text.secondary'>
Select a custom metric to see its value over time. This can help you
understand the impact of your feature rollout on key outcomes, such
as system performance, usage patterns or error rates.
</Typography>
<SeriesSelector
value={props.selectedSeries}
onChange={props.onSeriesChange}
options={props.metricSeries}
loading={props.loading}
/>
<RangeSelector
value={props.selectedRange}
onChange={props.onRangeChange}
/>
<BeginAtZeroToggle
value={props.beginAtZero}
onChange={props.onBeginAtZeroChange}
/>
{props.availableLabels && (
<LabelsFilter
selectedLabels={props.selectedLabels}
onChange={props.onLabelsChange}
availableLabels={props.availableLabels}
/>
)}
</Box>
);

View File

@ -0,0 +1,22 @@
import type { FC } from 'react';
import { FormControlLabel, Checkbox } from '@mui/material';
export type BeginAtZeroToggleProps = {
value: boolean;
onChange: (beginAtZero: boolean) => void;
};
export const BeginAtZeroToggle: FC<BeginAtZeroToggleProps> = ({
value,
onChange,
}) => (
<FormControlLabel
control={
<Checkbox
checked={value}
onChange={(e) => onChange(e.target.checked)}
/>
}
label='Begin at zero'
/>
);

View File

@ -0,0 +1,86 @@
import type { FC } from 'react';
import { Box, Autocomplete, TextField, Typography, Chip } from '@mui/material';
import type { ImpactMetricsLabels } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData';
export type LabelsFilterProps = {
selectedLabels: Record<string, string[]>;
onChange: (labels: Record<string, string[]>) => void;
availableLabels: ImpactMetricsLabels;
};
export const LabelsFilter: FC<LabelsFilterProps> = ({
selectedLabels,
onChange,
availableLabels,
}) => {
const handleLabelChange = (labelKey: string, values: string[]) => {
const newLabels = { ...selectedLabels };
if (values.length === 0) {
delete newLabels[labelKey];
} else {
newLabels[labelKey] = values;
}
onChange(newLabels);
};
const clearAllLabels = () => {
onChange({});
};
if (!availableLabels || Object.keys(availableLabels).length === 0) {
return null;
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant='subtitle2'>Filter by labels</Typography>
{Object.keys(selectedLabels).length > 0 && (
<Chip
label='Clear all'
size='small'
variant='outlined'
onClick={clearAllLabels}
/>
)}
</Box>
{Object.entries(availableLabels).map(([labelKey, values]) => (
<Autocomplete
key={labelKey}
multiple
options={values}
value={selectedLabels[labelKey] || []}
onChange={(_, newValues) =>
handleLabelChange(labelKey, newValues)
}
renderTags={(value, getTagProps) =>
value.map((option, index) => {
const { key, ...chipProps } = getTagProps({
index,
});
return (
<Chip
{...chipProps}
key={key}
label={option}
size='small'
/>
);
})
}
renderInput={(params) => (
<TextField
{...params}
label={labelKey}
placeholder='Select values...'
variant='outlined'
size='small'
/>
)}
sx={{ minWidth: 300 }}
/>
))}
</Box>
);
};

View File

@ -0,0 +1,26 @@
import type { FC } from 'react';
import { FormControl, InputLabel, Select, MenuItem } from '@mui/material';
export type TimeRange = 'hour' | 'day' | 'week' | 'month';
export type RangeSelectorProps = {
value: TimeRange;
onChange: (range: TimeRange) => void;
};
export const RangeSelector: FC<RangeSelectorProps> = ({ value, onChange }) => (
<FormControl variant='outlined' size='small' sx={{ minWidth: 200 }}>
<InputLabel id='range-select-label'>Time</InputLabel>
<Select
labelId='range-select-label'
value={value}
onChange={(e) => onChange(e.target.value as TimeRange)}
label='Time Range'
>
<MenuItem value='hour'>Last hour</MenuItem>
<MenuItem value='day'>Last 24 hours</MenuItem>
<MenuItem value='week'>Last 7 days</MenuItem>
<MenuItem value='month'>Last 30 days</MenuItem>
</Select>
</FormControl>
);

View File

@ -0,0 +1,55 @@
import type { FC } from 'react';
import { Autocomplete, TextField, Typography, Box } from '@mui/material';
import type { ImpactMetricsSeries } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata';
import { Highlighter } from 'component/common/Highlighter/Highlighter';
type SeriesOption = ImpactMetricsSeries & { name: string };
export type SeriesSelectorProps = {
value: string;
onChange: (series: string) => void;
options: SeriesOption[];
loading?: boolean;
};
export const SeriesSelector: FC<SeriesSelectorProps> = ({
value,
onChange,
options,
loading = false,
}) => (
<Autocomplete
options={options}
getOptionLabel={(option) => option.name}
value={options.find((option) => option.name === value) || null}
onChange={(_, newValue) => onChange(newValue?.name || '')}
disabled={loading}
renderOption={(props, option, { inputValue }) => (
<Box component='li' {...props}>
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography variant='body2'>
<Highlighter search={inputValue}>
{option.name}
</Highlighter>
</Typography>
<Typography variant='caption' color='text.secondary'>
<Highlighter search={inputValue}>
{option.help}
</Highlighter>
</Typography>
</Box>
</Box>
)}
renderInput={(params) => (
<TextField
{...params}
label='Data series'
placeholder='Search for a metric…'
variant='outlined'
size='small'
/>
)}
noOptionsText='No metrics available'
sx={{ minWidth: 300 }}
/>
);

View File

@ -1,216 +0,0 @@
import type { FC } from 'react';
import { useMemo, useState } from 'react';
import { Box, Typography, Alert } from '@mui/material';
import {
LineChart,
NotEnoughData,
} from '../components/LineChart/LineChart.tsx';
import { InsightsSection } from '../sections/InsightsSection.tsx';
import {
StyledChartContainer,
StyledWidget,
StyledWidgetStats,
} from 'component/insights/InsightsCharts.styles';
import { useImpactMetricsMetadata } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata';
import { useImpactMetricsData } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData';
import { usePlaceholderData } from '../hooks/usePlaceholderData.js';
import { ImpactMetricsControls } from './ImpactMetricsControls.tsx';
import { getDisplayFormat, getTimeUnit, formatLargeNumbers } from './utils.ts';
import { fromUnixTime } from 'date-fns';
import { useChartData } from './hooks/useChartData.ts';
export const ImpactMetrics: FC = () => {
const [selectedSeries, setSelectedSeries] = useState<string>('');
const [selectedRange, setSelectedRange] = useState<
'hour' | 'day' | 'week' | 'month'
>('day');
const [beginAtZero, setBeginAtZero] = useState(false);
const [selectedLabels, setSelectedLabels] = useState<
Record<string, string[]>
>({});
const handleSeriesChange = (series: string) => {
setSelectedSeries(series);
setSelectedLabels({}); // labels are series-specific
};
const {
metadata,
loading: metadataLoading,
error: metadataError,
} = useImpactMetricsMetadata();
const {
data: { start, end, series: timeSeriesData, labels: availableLabels },
loading: dataLoading,
error: dataError,
} = useImpactMetricsData(
selectedSeries
? {
series: selectedSeries,
range: selectedRange,
labels:
Object.keys(selectedLabels).length > 0
? selectedLabels
: undefined,
}
: undefined,
);
const placeholderData = usePlaceholderData({
fill: true,
type: 'constant',
});
const metricSeries = useMemo(() => {
if (!metadata?.series) {
return [];
}
return Object.entries(metadata.series).map(([name, rest]) => ({
name,
...rest,
}));
}, [metadata]);
const data = useChartData(timeSeriesData);
const hasError = metadataError || dataError;
const isLoading = metadataLoading || dataLoading;
const shouldShowPlaceholder = !selectedSeries || isLoading || hasError;
const notEnoughData = useMemo(
() =>
!isLoading &&
(!timeSeriesData ||
timeSeriesData.length === 0 ||
!data.datasets.some((d) => d.data.length > 1)),
[data, isLoading, timeSeriesData],
);
const minTime = start
? fromUnixTime(Number.parseInt(start, 10))
: undefined;
const maxTime = end ? fromUnixTime(Number.parseInt(end, 10)) : undefined;
const placeholder = selectedSeries ? (
<NotEnoughData description='Send impact metrics using Unleash SDK and select data series to view the chart.' />
) : (
<NotEnoughData
title='Select a metric series to view the chart.'
description=''
/>
);
const cover = notEnoughData ? placeholder : isLoading;
return (
<InsightsSection title='Impact metrics'>
<StyledWidget>
<StyledWidgetStats>
<Box
sx={(theme) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(2),
width: '100%',
})}
>
<ImpactMetricsControls
selectedSeries={selectedSeries}
onSeriesChange={handleSeriesChange}
selectedRange={selectedRange}
onRangeChange={setSelectedRange}
beginAtZero={beginAtZero}
onBeginAtZeroChange={setBeginAtZero}
metricSeries={metricSeries}
loading={metadataLoading}
selectedLabels={selectedLabels}
onLabelsChange={setSelectedLabels}
availableLabels={availableLabels}
/>
{!selectedSeries && !isLoading ? (
<Typography variant='body2' color='text.secondary'>
Select a metric series to view the chart
</Typography>
) : null}
</Box>
</StyledWidgetStats>
<StyledChartContainer>
{hasError ? (
<Alert severity='error'>
Failed to load impact metrics. Please check if
Prometheus is configured and the feature flag is
enabled.
</Alert>
) : null}
<LineChart
data={
notEnoughData || isLoading ? placeholderData : data
}
overrideOptions={
shouldShowPlaceholder
? {}
: {
scales: {
x: {
type: 'time',
min: minTime?.getTime(),
max: maxTime?.getTime(),
time: {
unit: getTimeUnit(
selectedRange,
),
displayFormats: {
[getTimeUnit(
selectedRange,
)]:
getDisplayFormat(
selectedRange,
),
},
tooltipFormat: 'PPpp',
},
},
y: {
beginAtZero,
title: {
display: false,
},
ticks: {
precision: 0,
callback: (
value: unknown,
): string | number =>
typeof value === 'number'
? formatLargeNumbers(
value,
)
: (value as number),
},
},
},
plugins: {
legend: {
display:
timeSeriesData &&
timeSeriesData.length > 1,
position: 'bottom' as const,
labels: {
usePointStyle: true,
boxWidth: 8,
padding: 12,
},
},
},
animations: {
x: { duration: 0 },
y: { duration: 0 },
},
}
}
cover={cover}
/>
</StyledChartContainer>
</StyledWidget>
</InsightsSection>
);
};

View File

@ -1,203 +0,0 @@
import type { FC } from 'react';
import {
FormControl,
InputLabel,
Select,
MenuItem,
FormControlLabel,
Checkbox,
Box,
Autocomplete,
TextField,
Typography,
Chip,
} from '@mui/material';
import type { ImpactMetricsSeries } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata';
import type { ImpactMetricsLabels } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData';
import { Highlighter } from 'component/common/Highlighter/Highlighter';
export interface ImpactMetricsControlsProps {
selectedSeries: string;
onSeriesChange: (series: string) => void;
selectedRange: 'hour' | 'day' | 'week' | 'month';
onRangeChange: (range: 'hour' | 'day' | 'week' | 'month') => void;
beginAtZero: boolean;
onBeginAtZeroChange: (beginAtZero: boolean) => void;
metricSeries: (ImpactMetricsSeries & { name: string })[];
loading?: boolean;
selectedLabels: Record<string, string[]>;
onLabelsChange: (labels: Record<string, string[]>) => void;
availableLabels?: ImpactMetricsLabels;
}
export const ImpactMetricsControls: FC<ImpactMetricsControlsProps> = ({
selectedSeries,
onSeriesChange,
selectedRange,
onRangeChange,
beginAtZero,
onBeginAtZeroChange,
metricSeries,
loading = false,
selectedLabels,
onLabelsChange,
availableLabels,
}) => {
const handleLabelChange = (labelKey: string, values: string[]) => {
const newLabels = { ...selectedLabels };
if (values.length === 0) {
delete newLabels[labelKey];
} else {
newLabels[labelKey] = values;
}
onLabelsChange(newLabels);
};
const clearAllLabels = () => {
onLabelsChange({});
};
return (
<Box
sx={(theme) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(3),
maxWidth: 400,
})}
>
<Typography variant='body2' color='text.secondary'>
Select a custom metric to see its value over time. This can help
you understand the impact of your feature rollout on key
outcomes, such as system performance, usage patterns or error
rates.
</Typography>
<Autocomplete
options={metricSeries}
getOptionLabel={(option) => option.name}
value={
metricSeries.find(
(option) => option.name === selectedSeries,
) || null
}
onChange={(_, newValue) => onSeriesChange(newValue?.name || '')}
disabled={loading}
renderOption={(props, option, { inputValue }) => (
<Box component='li' {...props}>
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography variant='body2'>
<Highlighter search={inputValue}>
{option.name}
</Highlighter>
</Typography>
<Typography
variant='caption'
color='text.secondary'
>
<Highlighter search={inputValue}>
{option.help}
</Highlighter>
</Typography>
</Box>
</Box>
)}
renderInput={(params) => (
<TextField
{...params}
label='Data series'
placeholder='Search for a metric…'
variant='outlined'
size='small'
/>
)}
noOptionsText='No metrics available'
sx={{ minWidth: 300 }}
/>
<FormControl variant='outlined' size='small' sx={{ minWidth: 200 }}>
<InputLabel id='range-select-label'>Time</InputLabel>
<Select
labelId='range-select-label'
value={selectedRange}
onChange={(e) =>
onRangeChange(
e.target.value as 'hour' | 'day' | 'week' | 'month',
)
}
label='Time Range'
>
<MenuItem value='hour'>Last hour</MenuItem>
<MenuItem value='day'>Last 24 hours</MenuItem>
<MenuItem value='week'>Last 7 days</MenuItem>
<MenuItem value='month'>Last 30 days</MenuItem>
</Select>
</FormControl>
<FormControlLabel
control={
<Checkbox
checked={beginAtZero}
onChange={(e) => onBeginAtZeroChange(e.target.checked)}
/>
}
label='Begin at zero'
/>
{availableLabels && Object.keys(availableLabels).length > 0 ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant='subtitle2'>
Filter by labels
</Typography>
{Object.keys(selectedLabels).length > 0 && (
<Chip
label='Clear all'
size='small'
variant='outlined'
onClick={clearAllLabels}
/>
)}
</Box>
{Object.entries(availableLabels).map(
([labelKey, values]) => (
<Autocomplete
key={labelKey}
multiple
options={values}
value={selectedLabels[labelKey] || []}
onChange={(_, newValues) =>
handleLabelChange(labelKey, newValues)
}
renderTags={(value, getTagProps) =>
value.map((option, index) => {
const { key, ...chipProps } =
getTagProps({ index });
return (
<Chip
{...chipProps}
key={key}
label={option}
size='small'
/>
);
})
}
renderInput={(params) => (
<TextField
{...params}
label={labelKey}
placeholder='Select values...'
variant='outlined'
size='small'
/>
)}
sx={{ minWidth: 300 }}
/>
),
)}
</Box>
) : null}
</Box>
);
};

View File

@ -1,85 +0,0 @@
import { useMemo } from 'react';
import { useTheme } from '@mui/material';
import type { ImpactMetricsSeries } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData';
import { useSeriesColor } from './useSeriesColor.ts';
import { getSeriesLabel } from '../utils.ts';
export const useChartData = (
timeSeriesData: ImpactMetricsSeries[] | undefined,
) => {
const theme = useTheme();
const getSeriesColor = useSeriesColor();
return useMemo(() => {
if (!timeSeriesData || timeSeriesData.length === 0) {
return {
labels: [],
datasets: [
{
data: [],
borderColor: theme.palette.primary.main,
backgroundColor: theme.palette.primary.light,
},
],
};
}
if (timeSeriesData.length === 1) {
const series = timeSeriesData[0];
const timestamps = series.data.map(
([epochTimestamp]) => new Date(epochTimestamp * 1000),
);
const values = series.data.map(([, value]) => value);
return {
labels: timestamps,
datasets: [
{
data: values,
borderColor: theme.palette.primary.main,
backgroundColor: theme.palette.primary.light,
label: getSeriesLabel(series.metric),
},
],
};
} else {
const allTimestamps = new Set<number>();
timeSeriesData.forEach((series) => {
series.data.forEach(([timestamp]) => {
allTimestamps.add(timestamp);
});
});
const sortedTimestamps = Array.from(allTimestamps).sort(
(a, b) => a - b,
);
const labels = sortedTimestamps.map(
(timestamp) => new Date(timestamp * 1000),
);
const datasets = timeSeriesData.map((series) => {
const seriesLabel = getSeriesLabel(series.metric);
const color = getSeriesColor(seriesLabel);
const dataMap = new Map(series.data);
const data = sortedTimestamps.map(
(timestamp) => dataMap.get(timestamp) ?? null,
);
return {
label: seriesLabel,
data,
borderColor: color,
backgroundColor: color,
fill: false,
spanGaps: false,
};
});
return {
labels,
datasets,
};
}
}, [timeSeriesData, theme, getSeriesColor]);
};

View File

@ -1,17 +0,0 @@
import { useTheme } from '@mui/material';
export const useSeriesColor = () => {
const theme = useTheme();
const colors = theme.palette.charts.series;
return (seriesLabel: string): string => {
let hash = 0;
for (let i = 0; i < seriesLabel.length; i++) {
const char = seriesLabel.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32-bit integer
}
const index = Math.abs(hash) % colors.length;
return colors[index];
};
};

View File

@ -1,60 +0,0 @@
export const getTimeUnit = (selectedRange: string) => {
switch (selectedRange) {
case 'hour':
return 'minute';
case 'day':
return 'hour';
case 'week':
return 'day';
case 'month':
return 'day';
default:
return 'hour';
}
};
export const getDisplayFormat = (selectedRange: string) => {
switch (selectedRange) {
case 'hour':
case 'day':
return 'HH:mm';
case 'week':
case 'month':
return 'MMM dd';
default:
return 'MMM dd HH:mm';
}
};
export const getSeriesLabel = (metric: Record<string, string>): string => {
const { __name__, ...labels } = metric;
const labelParts = Object.entries(labels)
.filter(([key, value]) => key !== '__name__' && value)
.map(([key, value]) => `${key}=${value}`)
.join(', ');
if (!__name__ && !labelParts) {
return 'Series';
}
if (!__name__) {
return labelParts;
}
if (!labelParts) {
return __name__;
}
return `${__name__} (${labelParts})`;
};
export const formatLargeNumbers = (value: number): string => {
if (value >= 1000000) {
return `${(value / 1000000).toFixed(0)}M`;
}
if (value >= 1000) {
return `${(value / 1000).toFixed(0)}k`;
}
return value.toString();
};

View File

@ -93,6 +93,7 @@ export type UiFlags = {
impactMetrics?: boolean;
crDiffView?: boolean;
changeRequestApproverEmails?: boolean;
eventGrouping?: boolean;
};
export interface IVersionInfo {

View File

@ -196,8 +196,14 @@ export default class ClientMetricsServiceV2 {
}
async registerImpactMetrics(impactMetrics: Metric[]) {
const value = await impactMetricsSchema.validateAsync(impactMetrics);
this.impactMetricsTranslator.translateMetrics(value);
try {
const value =
await impactMetricsSchema.validateAsync(impactMetrics);
this.impactMetricsTranslator.translateMetrics(value);
} catch (e) {
// impact metrics should not affect other metrics on failure
this.logger.warn(e);
}
}
async registerClientMetrics(

View File

@ -26,6 +26,20 @@ const sendImpactMetrics = async (impactMetrics: Metric[], status = 202) =>
})
.expect(status);
const sendBulkMetricsWithImpact = async (
impactMetrics: Metric[],
status = 202,
) => {
return app.request
.post('/api/client/metrics/bulk')
.send({
applications: [],
metrics: [],
impactMetrics,
})
.expect(status);
};
beforeAll(async () => {
db = await dbInit('impact_metrics', getLogger);
app = await setupAppWithCustomConfig(db.stores, {
@ -72,7 +86,7 @@ test('should store impact metrics in memory and be able to retrieve them', async
]);
await sendImpactMetrics([]);
// missing help
// missing help = no error but value ignored
await sendImpactMetrics(
[
// @ts-expect-error
@ -87,7 +101,7 @@ test('should store impact metrics in memory and be able to retrieve them', async
],
},
],
400,
202,
);
const response = await app.request
@ -101,3 +115,48 @@ test('should store impact metrics in memory and be able to retrieve them', async
expect(metricsText).toContain('# TYPE labeled_counter counter');
expect(metricsText).toMatch(/labeled_counter{foo="bar"} 15/);
});
test('should store impact metrics sent via bulk metrics endpoint', async () => {
await sendBulkMetricsWithImpact([
{
name: 'bulk_counter',
help: 'bulk counter with labels',
type: 'counter',
samples: [
{
labels: { source: 'bulk' },
value: 7,
},
],
},
]);
await sendBulkMetricsWithImpact([
{
name: 'bulk_counter',
help: 'bulk counter with labels',
type: 'counter',
samples: [
{
labels: { source: 'bulk' },
value: 8,
},
],
},
]);
await sendBulkMetricsWithImpact([]);
const response = await app.request
.get('/internal-backstage/impact/metrics')
.expect('Content-Type', /text/)
.expect(200);
const metricsText = response.text;
expect(metricsText).toContain(
'# HELP bulk_counter bulk counter with labels',
);
expect(metricsText).toContain('# TYPE bulk_counter counter');
expect(metricsText).toMatch(/bulk_counter{source="bulk"} 15/);
});

View File

@ -230,7 +230,7 @@ export default class ClientMetricsController extends Controller {
res.status(204).end();
} else {
const { body, ip: clientIp } = req;
const { metrics, applications } = body;
const { metrics, applications, impactMetrics } = body;
try {
const promises: Promise<void>[] = [];
for (const app of applications) {
@ -275,6 +275,17 @@ export default class ClientMetricsController extends Controller {
);
this.config.eventBus.emit(CLIENT_METRICS, data);
}
if (
this.flagResolver.isEnabled('impactMetrics') &&
impactMetrics &&
impactMetrics.length > 0
) {
promises.push(
this.metricsV2.registerImpactMetrics(impactMetrics),
);
}
await Promise.all(promises);
res.status(202).end();

View File

@ -2,6 +2,7 @@ import type { FromSchema } from 'json-schema-to-ts';
import { bulkRegistrationSchema } from './bulk-registration-schema.js';
import { dateSchema } from './date-schema.js';
import { clientMetricsEnvSchema } from './client-metrics-env-schema.js';
import { impactMetricsSchema } from './impact-metrics-schema.js';
export const bulkMetricsSchema = {
$id: '#/components/schemas/bulkMetricsSchema',
@ -25,12 +26,21 @@ export const bulkMetricsSchema = {
$ref: '#/components/schemas/clientMetricsEnvSchema',
},
},
impactMetrics: {
description:
'a list of custom impact metrics registered by downstream providers. (Typically Unleash Edge)',
type: 'array',
items: {
$ref: '#/components/schemas/impactMetricsSchema',
},
},
},
components: {
schemas: {
bulkRegistrationSchema,
dateSchema,
clientMetricsEnvSchema,
impactMetricsSchema,
},
},
} as const;

View File

@ -0,0 +1,60 @@
import type { FromSchema } from 'json-schema-to-ts';
export const impactMetricsSchema = {
$id: '#/components/schemas/impactMetricsSchema',
type: 'object',
required: ['name', 'help', 'type', 'samples'],
description: 'Used for reporting impact metrics from SDKs',
properties: {
name: {
type: 'string',
description: 'Name of the impact metric',
example: 'my-counter',
},
help: {
description:
'Human-readable description of what the metric measures',
type: 'string',
example: 'Counts the number of operations',
},
type: {
description: 'Type of the metric',
type: 'string',
enum: ['counter', 'gauge'],
example: 'counter',
},
samples: {
description: 'Samples of the metric',
type: 'array',
items: {
type: 'object',
required: ['value'],
description:
'A sample of a metric with a value and optional labels',
properties: {
value: {
type: 'number',
description: 'The value of the metric sample',
example: 10,
},
labels: {
description: 'Optional labels for the metric sample',
type: 'object',
additionalProperties: {
type: 'string',
},
example: {
application: 'my-app',
environment: 'production',
},
},
},
},
},
},
components: {
schemas: {},
},
} as const;
export type ImpactMetricsSchema = FromSchema<typeof impactMetricsSchema>;

View File

@ -113,6 +113,7 @@ export * from './health-overview-schema.js';
export * from './health-report-schema.js';
export * from './id-schema.js';
export * from './ids-schema.js';
export * from './impact-metrics-schema.js';
export * from './import-toggles-schema.js';
export * from './import-toggles-validate-item-schema.js';
export * from './import-toggles-validate-schema.js';

View File

@ -62,7 +62,8 @@ export type IFlagKey =
| 'createFlagDialogCache'
| 'improvedJsonDiff'
| 'crDiffView'
| 'changeRequestApproverEmails';
| 'changeRequestApproverEmails'
| 'eventGrouping';
export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>;
@ -291,6 +292,10 @@ const flags: IFlags = {
process.env.UNLEASH_EXPERIMENTAL_IMPACT_METRICS,
false,
),
eventGrouping: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_EVENT_GROUPING,
false,
),
};
export const defaultExperimentalOptions: IExperimentalOptions = {

View File

@ -58,6 +58,7 @@ process.nextTick(async () => {
improvedJsonDiff: true,
impactMetrics: true,
crDiffView: true,
eventGrouping: true,
},
},
authentication: {