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:
commit
400d3ce2a2
258
.github/workflows/ai-flag-cleanup-pr.yml
vendored
Normal file
258
.github/workflows/ai-flag-cleanup-pr.yml
vendored
Normal 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"
|
24
.github/workflows/ai-flag-cleanup.yml
vendored
24
.github/workflows/ai-flag-cleanup.yml
vendored
@ -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 }}
|
||||
|
@ -1 +0,0 @@
|
||||
This section has been moved to a separate repository: https://github.com/Unleash/unleash-examples
|
@ -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)}
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
@ -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'
|
||||
/>
|
||||
);
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
@ -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 }}
|
||||
/>
|
||||
);
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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]);
|
||||
};
|
@ -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];
|
||||
};
|
||||
};
|
@ -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();
|
||||
};
|
@ -93,6 +93,7 @@ export type UiFlags = {
|
||||
impactMetrics?: boolean;
|
||||
crDiffView?: boolean;
|
||||
changeRequestApproverEmails?: boolean;
|
||||
eventGrouping?: boolean;
|
||||
};
|
||||
|
||||
export interface IVersionInfo {
|
||||
|
@ -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(
|
||||
|
@ -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/);
|
||||
});
|
||||
|
@ -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();
|
||||
|
@ -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;
|
||||
|
60
src/lib/openapi/spec/impact-metrics-schema.ts
Normal file
60
src/lib/openapi/spec/impact-metrics-schema.ts
Normal 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>;
|
@ -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';
|
||||
|
@ -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 = {
|
||||
|
@ -58,6 +58,7 @@ process.nextTick(async () => {
|
||||
improvedJsonDiff: true,
|
||||
impactMetrics: true,
|
||||
crDiffView: true,
|
||||
eventGrouping: true,
|
||||
},
|
||||
},
|
||||
authentication: {
|
||||
|
Loading…
Reference in New Issue
Block a user