1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-06-27 01:19:00 +02:00

Merge remote-tracking branch 'origin/main' into feat/impact-metrics-frontend

This commit is contained in:
Tymoteusz Czech 2025-06-23 14:13:11 +02:00
commit 73ac8fad8e
No known key found for this signature in database
GPG Key ID: 133555230D88D75F
56 changed files with 1457 additions and 941 deletions

View File

@ -19,7 +19,7 @@ jobs:
run: echo "PR_CREATOR=${{ github.event.pull_request.user.login }}" >> $GITHUB_ENV
- name: Post a notification about core feature changes if not already commented
uses: actions/github-script@v6
uses: actions/github-script@v7
with:
script: |
const prCreator = process.env.PR_CREATOR;
@ -45,7 +45,7 @@ jobs:
console.log('Comment already exists, skipping.');
}
- name: Add reviewers to the PR if they are not the creator
uses: actions/github-script@v6
uses: actions/github-script@v7
with:
script: |
const prCreator = process.env.PR_CREATOR;

View File

@ -2,6 +2,46 @@
All notable changes to this project will be documented in this file.
## [7.0.3] - 2025-06-17
## [7.0.2] - 2025-06-17
### Bug Fixes
- Correct upgrade link ([#10138](https://github.com/Unleash/unleash/issues/10138))
### Features
- Report hostedBy and licenseType ([#10141](https://github.com/Unleash/unleash/issues/10141))
- Translate impact metrics to prom format ([#10147](https://github.com/Unleash/unleash/issues/10147))
- Expose impact metrics ([#10151](https://github.com/Unleash/unleash/issues/10151))
### Miscellaneous Tasks
- Clean up flag overview redesign ([#10140](https://github.com/Unleash/unleash/issues/10140))
- Remove flag enterprise-payg ([#10139](https://github.com/Unleash/unleash/issues/10139))
- Added flag for CR approver emails ([#10144](https://github.com/Unleash/unleash/issues/10144))
- Add PSF to approved licenses list ([#10148](https://github.com/Unleash/unleash/issues/10148))
- Now expose IFeatureUsageInfo to override telemetry checking ([#10149](https://github.com/Unleash/unleash/issues/10149))
- Improve json diff view ([#10146](https://github.com/Unleash/unleash/issues/10146))
- Use logger instead of console.error ([#10150](https://github.com/Unleash/unleash/issues/10150))
### Refactor
- Migrate from make-fetch-happen to ky and use ky natively ([#10134](https://github.com/Unleash/unleash/issues/10134))
## [7.0.1] - 2025-06-13
### Bug Fixes

View File

@ -64,10 +64,10 @@
"@types/react-router-dom": "5.3.3",
"@types/react-table": "7.7.20",
"@types/react-test-renderer": "18.3.1",
"@types/semver": "7.5.8",
"@types/semver": "7.7.0",
"@types/uuid": "^9.0.0",
"@uiw/codemirror-theme-duotone": "4.23.10",
"@uiw/react-codemirror": "4.23.10",
"@uiw/codemirror-theme-duotone": "4.23.13",
"@uiw/react-codemirror": "4.23.13",
"@unleash/proxy-client-react": "^5.0.0",
"@vitejs/plugin-react": "4.3.4",
"cartesian": "^1.0.1",
@ -114,7 +114,7 @@
"react-table": "7.8.0",
"react-test-renderer": "18.3.1",
"sass": "1.85.1",
"semver": "7.7.1",
"semver": "7.7.2",
"swr": "2.3.3",
"tss-react": "4.9.15",
"typescript": "5.8.3",
@ -134,7 +134,7 @@
"jsonpath-plus": "10.3.0",
"json5": "^2.2.2",
"vite": "5.4.19",
"semver": "7.7.1",
"semver": "7.7.2",
"ws": "^8.18.0",
"@types/react": "18.3.18"
},

View File

@ -1,5 +1,5 @@
import type React from 'react';
import { useImperativeHandle } from 'react';
import { useEffect, useImperativeHandle } from 'react';
import { forwardRef } from 'react';
import { styled } from '@mui/material';
import type { IConstraint } from 'interfaces/strategy';
@ -9,6 +9,7 @@ import { ConstraintsList } from 'component/common/ConstraintsList/ConstraintsLis
import { EditableConstraint } from 'component/feature/FeatureStrategy/FeatureStrategyConstraints/EditableConstraint/EditableConstraint';
import { createEmptyConstraint } from '../../../../utils/createEmptyConstraint.ts';
import { constraintId } from 'constants/constraintId.ts';
import { v4 as uuidv4 } from 'uuid';
export interface IEditableConstraintsListRef {
addConstraint?: (contextName: string) => void;
}
@ -39,6 +40,17 @@ export const EditableConstraintsList = forwardRef<
},
}));
useEffect(() => {
if (!constraints.every((constraint) => constraintId in constraint)) {
setConstraints(
constraints.map((constraint) => ({
[constraintId]: uuidv4(),
...constraint,
})),
);
}
}, [constraints, setConstraints]);
const onDelete = (index: number) => {
setConstraints(
produce((draft) => {

View File

@ -23,6 +23,7 @@ interface IEventDiffProps {
* @deprecated remove with flag improvedJsonDiff
*/
sort?: (a: IEventDiffResult, b: IEventDiffResult) => number;
excludeKeys?: string[];
}
const DiffStyles = styled('div')(({ theme }) => ({
@ -37,7 +38,6 @@ const DiffStyles = styled('div')(({ theme }) => ({
position: 'absolute',
left: 0,
top: 0,
marginLeft: '-10px',
},
},
@ -47,35 +47,65 @@ const DiffStyles = styled('div')(({ theme }) => ({
content: '"+"',
},
},
'.deletion': {
color: theme.palette.eventLog.diffSub,
'::before': {
content: '"-"',
},
},
'&[data-change-type="replacement"]': {
':has(.addition)': {
color: theme.palette.eventLog.diffAdd,
},
':has(.deletion)': {
color: theme.palette.eventLog.diffSub,
},
'.addition::before, .deletion::before': {
content: 'none',
},
},
'.diff:not(:has(*))': {
'::before': {
content: '"(no changes)"',
},
},
}));
const NewEventDiff: FC<IEventDiffProps> = ({ entry }) => {
const ButtonIcon = styled('span')(({ theme }) => ({
marginInlineEnd: theme.spacing(0.5),
}));
const NewEventDiff: FC<IEventDiffProps> = ({ entry, excludeKeys }) => {
const changeType = entry.preData && entry.data ? 'edit' : 'replacement';
const showExpandButton = changeType === 'edit';
const [full, setFull] = useState(false);
const diffId = useId();
return (
<>
<Button
onClick={() => setFull(!full)}
aria-controls={diffId}
aria-expanded={full}
>
{full ? 'Show only changed properties' : 'Show all properties'}
</Button>
<DiffStyles id={diffId}>
{showExpandButton ? (
<Button
onClick={() => setFull(!full)}
aria-controls={diffId}
aria-expanded={full}
>
<ButtonIcon aria-hidden>{full ? '-' : '+'}</ButtonIcon>
{full
? 'Show only changed properties'
: 'Show all properties'}
</Button>
) : null}
<DiffStyles data-change-type={changeType} id={diffId}>
<JsonDiffComponent
jsonA={(entry.preData ?? {}) as JsonValue}
jsonB={(entry.data ?? {}) as JsonValue}
jsonDiffOptions={{
full: full,
maxElisions: 2,
excludeKeys: ['id', 'createdAt', 'updatedAt'],
excludeKeys: excludeKeys,
}}
/>
</DiffStyles>

View File

@ -72,6 +72,7 @@ export const useEventLogSearch = (
createdBy: FilterItemParam,
type: FilterItemParam,
environment: FilterItemParam,
id: StringParam,
...extraParameters(logType),
};

View File

@ -0,0 +1,32 @@
import { createEmptyConstraint } from 'utils/createEmptyConstraint';
import { fromIConstraint, toIConstraint } from './editable-constraint-type.ts';
import { constraintId } from 'constants/constraintId';
import type { IConstraint } from 'interfaces/strategy.ts';
test('mapping to and from retains the constraint id', () => {
const constraint = createEmptyConstraint('context');
expect(toIConstraint(fromIConstraint(constraint))[constraintId]).toEqual(
constraint[constraintId],
);
});
test('mapping to an editable constraint adds a constraint id if there is none', () => {
const constraint: IConstraint = createEmptyConstraint('context');
delete constraint[constraintId];
const editableConstraint = fromIConstraint(constraint);
expect(editableConstraint[constraintId]).toBeDefined();
const iConstraint = toIConstraint(editableConstraint);
expect(iConstraint[constraintId]).toEqual(editableConstraint[constraintId]);
});
test('mapping from an empty constraint removes redundant value / values', () => {
const constraint = { ...createEmptyConstraint('context'), value: '' };
expect(constraint).toHaveProperty('value');
const transformed = toIConstraint(fromIConstraint(constraint));
expect(transformed).not.toHaveProperty('value');
});

View File

@ -1,3 +1,4 @@
import { constraintId } from 'constants/constraintId';
import {
type DateOperator,
isDateOperator,
@ -10,6 +11,7 @@ import {
isSemVerOperator,
} from 'constants/operators';
import type { IConstraint } from 'interfaces/strategy';
import { v4 as uuidv4 } from 'uuid';
type EditableConstraintBase = Omit<
IConstraint,
@ -72,12 +74,14 @@ export const fromIConstraint = (
const { value, values, operator, ...rest } = constraint;
if (isSingleValueOperator(operator)) {
return {
[constraintId]: uuidv4(),
...rest,
operator,
value: value ?? '',
};
} else {
return {
[constraintId]: uuidv4(),
...rest,
operator,
values: new Set(values),
@ -92,7 +96,7 @@ export const toIConstraint = (constraint: EditableConstraint): IConstraint => {
const { values, ...rest } = constraint;
return {
...rest,
values: Array.from(values),
values: Array.from(constraint.values),
};
}
};

View File

@ -40,11 +40,13 @@ test('calls onUpdate with new state', async () => {
// gets called by useEffect, so we need to wait for the next render.
await waitFor(() => {
expect(onUpdate).toHaveBeenCalledWith({
contextName: 'context-field',
operator: IN,
values: [],
});
expect(onUpdate).toHaveBeenCalledWith(
expect.objectContaining({
contextName: 'context-field',
operator: IN,
values: [],
}),
);
});
});

View File

@ -37,6 +37,7 @@ import { useDefaultStrategy } from '../../../project/Project/ProjectSettings/Pro
import { FeatureStrategyForm } from '../FeatureStrategyForm/FeatureStrategyForm.tsx';
import { NewStrategyVariants } from 'component/feature/StrategyTypes/NewStrategyVariants';
import { Limit } from 'component/common/Limit/Limit';
import { apiPayloadConstraintReplacer } from 'utils/api-payload-constraint-replacer.ts';
const useStrategyLimit = (strategyCount: number) => {
const { uiConfig } = useUiConfig();
@ -280,7 +281,7 @@ export const formatAddStrategyApiCode = (
}
const url = `${unleashUrl}/api/admin/projects/${projectId}/features/${featureId}/environments/${environmentId}/strategies`;
const payload = JSON.stringify(strategy, undefined, 2);
const payload = JSON.stringify(strategy, apiPayloadConstraintReplacer, 2);
return `curl --location --request POST '${url}' \\
--header 'Authorization: INSERT_API_KEY' \\

View File

@ -35,6 +35,7 @@ import {
getChangeRequestConflictCreatedDataFromScheduleData,
} from './change-request-conflict-data.ts';
import { constraintId } from 'constants/constraintId.ts';
import { apiPayloadConstraintReplacer } from 'utils/api-payload-constraint-replacer.ts';
const useTitleTracking = () => {
const [previousTitle, setPreviousTitle] = useState<string>('');
@ -352,7 +353,11 @@ export const formatUpdateStrategyApiCode = (
};
const url = `${unleashUrl}/api/admin/projects/${projectId}/features/${featureId}/environments/${environmentId}/strategies/${strategyId}`;
const payload = JSON.stringify(sortedStrategy, undefined, 2);
const payload = JSON.stringify(
sortedStrategy,
apiPayloadConstraintReplacer,
2,
);
return `curl --location --request PUT '${url}' \\
--header 'Authorization: INSERT_API_KEY' \\

View File

@ -143,28 +143,6 @@ const StyledAlertBox = styled(Box)(({ theme }) => ({
},
}));
const StyledEnvironmentBox = styled(Box)(({ theme }) => ({
display: 'flex',
alignItems: 'center',
}));
const EnvironmentIconBox = styled(Box)(({ theme }) => ({
transform: 'scale(0.9)',
display: 'flex',
alignItems: 'center',
}));
const EnvironmentTypography = styled(Typography, {
shouldForwardProp: (prop) => prop !== 'enabled',
})<{ enabled: boolean }>(({ enabled }) => ({
fontWeight: enabled ? 'bold' : 'normal',
}));
const EnvironmentTypographyHeader = styled(Typography)(({ theme }) => ({
marginRight: theme.spacing(0.5),
color: theme.palette.text.secondary,
}));
const StyledTab = styled(Tab)(({ theme }) => ({
width: '100px',
}));
@ -173,11 +151,11 @@ const StyledBadge = styled(Badge)(({ theme }) => ({
marginLeft: theme.spacing(1),
}));
const StyledConstraintSeparator = styled(ConstraintSeparator)(({ theme }) => ({
const StyledConstraintSeparator = styled(ConstraintSeparator)({
top: '-10px',
left: '0',
transform: 'translateY(0)',
}));
});
export const FeatureStrategyForm = ({
projectId,

View File

@ -59,6 +59,7 @@ export const StyledProjectTitle = styled('h1')(({ theme }) => ({
alignItems: 'center',
gap: theme.spacing(2),
overflow: 'hidden',
lineHeight: 1.5,
}));
export const StyledSeparator = styled('div')(({ theme }) => ({

View File

@ -9,7 +9,6 @@ import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
import { BulkDisableDialog } from 'component/feature/FeatureToggleList/BulkDisableDialog';
import { BulkEnableDialog } from 'component/feature/FeatureToggleList/BulkEnableDialog';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
interface IProjectFeaturesBatchActionsProps {
selectedIds: string[];
@ -72,32 +71,20 @@ export const ProjectFeaturesBatchActions: FC<
return (
<>
<ConditionallyRender
condition={Boolean(uiConfig?.flags?.disableBulkToggle)}
show={null}
elseShow={
<Button
variant='outlined'
size='small'
onClick={() => setShowBulkEnableDialog(true)}
>
Enable
</Button>
}
/>
<ConditionallyRender
condition={Boolean(uiConfig?.flags?.disableBulkToggle)}
show={null}
elseShow={
<Button
variant='outlined'
size='small'
onClick={() => setShowBulkDisableDialog(true)}
>
Disable
</Button>
}
/>
<Button
variant='outlined'
size='small'
onClick={() => setShowBulkEnableDialog(true)}
>
Enable
</Button>
<Button
variant='outlined'
size='small'
onClick={() => setShowBulkDisableDialog(true)}
>
Disable
</Button>
<ArchiveButton
projectId={projectId}
featureIds={selectedIds}

View File

@ -21,6 +21,7 @@ import { useSegmentValuesCount } from 'component/segments/hooks/useSegmentValues
import { SEGMENT_CREATE_BTN_ID } from 'utils/testIds';
import { useSegmentLimits } from 'hooks/api/getters/useSegmentLimits/useSegmentLimits';
import { useOptionalPathParam } from 'hooks/useOptionalPathParam';
import { apiPayloadConstraintReplacer } from 'utils/api-payload-constraint-replacer.ts';
interface ICreateSegmentProps {
modal?: boolean;
@ -61,7 +62,7 @@ export const CreateSegment = ({ modal }: ICreateSegmentProps) => {
return `curl --location --request POST '${uiConfig.unleashUrl}/api/admin/segments' \\
--header 'Authorization: INSERT_API_KEY' \\
--header 'Content-Type: application/json' \\
--data-raw '${JSON.stringify(getSegmentPayload(), undefined, 2)}'`;
--data-raw '${JSON.stringify(getSegmentPayload(), apiPayloadConstraintReplacer, 2)}'`;
};
const handleSubmit = async (e: React.FormEvent) => {

View File

@ -24,15 +24,32 @@ import { useSegmentLimits } from 'hooks/api/getters/useSegmentLimits/useSegmentL
import { useOptionalPathParam } from 'hooks/useOptionalPathParam';
import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi';
import { useHighestPermissionChangeRequestEnvironment } from 'hooks/useHighestPermissionChangeRequestEnvironment';
import type { ISegment } from 'interfaces/segment.ts';
import { constraintId } from 'constants/constraintId.ts';
import { v4 as uuidv4 } from 'uuid';
import { apiPayloadConstraintReplacer } from 'utils/api-payload-constraint-replacer.ts';
interface IEditSegmentProps {
modal?: boolean;
}
const addIdSymbolToConstraints = (segment?: ISegment): ISegment | undefined => {
if (!segment) return;
const constraints = segment.constraints.map((constraint) => {
return { ...constraint, [constraintId]: uuidv4() };
});
return { ...segment, constraints };
};
export const EditSegment = ({ modal }: IEditSegmentProps) => {
const projectId = useOptionalPathParam('projectId');
const segmentId = useRequiredPathParam('segmentId');
const { segment } = useSegment(Number(segmentId));
const { segment: segmentWithoutConstraintIds } = useSegment(
Number(segmentId),
);
const segment = addIdSymbolToConstraints(segmentWithoutConstraintIds);
const { uiConfig } = useUiConfig();
const { setToastData, setToastApiError } = useToast();
const navigate = useNavigate();
@ -72,7 +89,7 @@ export const EditSegment = ({ modal }: IEditSegmentProps) => {
}/api/admin/segments/${segmentId}' \\
--header 'Authorization: INSERT_API_KEY' \\
--header 'Content-Type: application/json' \\
--data-raw '${JSON.stringify(getSegmentPayload(), undefined, 2)}'`;
--data-raw '${JSON.stringify(getSegmentPayload(), apiPayloadConstraintReplacer, 2)}'`;
};
const highestPermissionChangeRequestEnv =

View File

@ -1,4 +1,4 @@
import type { IConstraint } from 'interfaces/strategy';
import type { IConstraint, IConstraintWithId } from 'interfaces/strategy';
import { SegmentFormStepOne } from './SegmentFormStepOne.tsx';
import { SegmentFormStepTwo } from './SegmentFormStepTwo.tsx';
import type React from 'react';
@ -14,7 +14,7 @@ interface ISegmentProps {
name: string;
description: string;
project?: string;
constraints: IConstraint[];
constraints: IConstraintWithId[];
setName: React.Dispatch<React.SetStateAction<string>>;
setDescription: React.Dispatch<React.SetStateAction<string>>;
setProject: React.Dispatch<React.SetStateAction<string | undefined>>;

View File

@ -3,12 +3,12 @@ import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { testServerRoute, testServerSetup } from 'utils/testServer';
import { SegmentFormStepTwo } from './SegmentFormStepTwo.tsx';
import type { IConstraint } from 'interfaces/strategy';
import { vi } from 'vitest';
import {
CREATE_SEGMENT,
UPDATE_PROJECT_SEGMENT,
} from 'component/providers/AccessProvider/permissions';
import type { IConstraintWithId } from 'interfaces/strategy.ts';
const server = testServerSetup();
@ -26,7 +26,7 @@ const setupRoutes = () => {
const defaultProps = {
project: undefined,
constraints: [] as IConstraint[],
constraints: [] as IConstraintWithId[],
setConstraints: vi.fn(),
setCurrentStep: vi.fn(),
mode: 'create' as const,

View File

@ -13,7 +13,7 @@ import {
UPDATE_SEGMENT,
} from 'component/providers/AccessProvider/permissions';
import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext';
import type { IConstraint } from 'interfaces/strategy';
import type { IConstraint, IConstraintWithId } from 'interfaces/strategy';
import { useNavigate } from 'react-router-dom';
import { EditableConstraintsList } from 'component/common/NewConstraintAccordion/ConstraintsList/EditableConstraintsList';
import type { IEditableConstraintsListRef } from 'component/common/NewConstraintAccordion/ConstraintsList/EditableConstraintsList';
@ -33,7 +33,7 @@ import { GO_BACK } from 'constants/navigate';
interface ISegmentFormPartTwoProps {
project?: string;
constraints: IConstraint[];
constraints: IConstraintWithId[];
setConstraints: React.Dispatch<React.SetStateAction<IConstraint[]>>;
setCurrentStep: React.Dispatch<React.SetStateAction<SegmentFormStep>>;
mode: SegmentFormMode;

View File

@ -1,6 +1,8 @@
import type { IConstraint } from 'interfaces/strategy';
import type { IConstraint, IConstraintWithId } from 'interfaces/strategy';
import { useEffect, useState } from 'react';
import { useSegmentValidation } from 'hooks/api/getters/useSegmentValidation/useSegmentValidation';
import { v4 as uuidv4 } from 'uuid';
import { constraintId } from 'constants/constraintId';
export const useSegmentForm = (
initialName = '',
@ -11,8 +13,13 @@ export const useSegmentForm = (
const [name, setName] = useState(initialName);
const [description, setDescription] = useState(initialDescription);
const [project, setProject] = useState<string | undefined>(initialProject);
const [constraints, setConstraints] =
useState<IConstraint[]>(initialConstraints);
const initialConstraintsWithId = initialConstraints.map((constraint) => ({
[constraintId]: uuidv4(),
...constraint,
}));
const [constraints, setConstraints] = useState<IConstraintWithId[]>(
initialConstraintsWithId,
);
const [errors, setErrors] = useState({});
const nameError = useSegmentValidation(name, initialName);
@ -29,7 +36,7 @@ export const useSegmentForm = (
}, [initialProject]);
useEffect(() => {
setConstraints(initialConstraints);
setConstraints(initialConstraintsWithId);
// eslint-disable-next-line
}, [JSON.stringify(initialConstraints)]);
@ -61,7 +68,9 @@ export const useSegmentForm = (
project,
setProject,
constraints,
setConstraints,
setConstraints: setConstraints as React.Dispatch<
React.SetStateAction<IConstraint[]>
>,
getSegmentPayload,
clearErrors,
errors,

View File

@ -68,6 +68,10 @@ export interface IConstraint {
[constraintId]?: string;
}
export interface IConstraintWithId extends IConstraint {
[constraintId]: string;
}
export interface IFeatureStrategySortOrder {
id: string;
sortOrder: number;

View File

@ -59,7 +59,6 @@ export type UiFlags = {
personalAccessTokensKillSwitch?: boolean;
demo?: boolean;
googleAuthEnabled?: boolean;
disableBulkToggle?: boolean;
advancedPlayground?: boolean;
strategyVariant?: boolean;
doraMetrics?: boolean;

View File

@ -0,0 +1,57 @@
import type { IConstraint } from 'interfaces/strategy';
import { serializeConstraint } from './api-payload-constraint-replacer.ts';
import { constraintId } from 'constants/constraintId';
test('keys are ordered in the expected order', () => {
const input: IConstraint = {
values: ['something'],
inverted: true,
operator: 'STR_CONTAINS',
contextName: 'context',
caseInsensitive: true,
};
const output = serializeConstraint(input);
expect(Object.entries(output)).toStrictEqual([
['contextName', 'context'],
['operator', 'STR_CONTAINS'],
['values', ['something']],
['caseInsensitive', true],
['inverted', true],
]);
});
test('only value OR values is present, not both', () => {
const input: IConstraint = {
value: 'something',
values: ['something else'],
inverted: true,
operator: 'IN',
contextName: 'context',
caseInsensitive: true,
};
const noValue = serializeConstraint(input);
expect(noValue.values).toStrictEqual(['something else']);
expect(noValue).not.toHaveProperty('value');
const noValues = serializeConstraint({
...input,
operator: 'SEMVER_EQ',
});
expect(noValues.value).toStrictEqual('something');
expect(noValues).not.toHaveProperty('values');
});
test('constraint id is not included', () => {
const input: IConstraint = {
[constraintId]: 'constraint-id',
value: 'something',
operator: 'IN',
contextName: 'context',
};
const output = serializeConstraint(input);
expect(constraintId in output).toBeFalsy();
});

View File

@ -0,0 +1,39 @@
import { isSingleValueOperator } from 'constants/operators';
import type { IConstraint } from 'interfaces/strategy';
export const serializeConstraint = ({
value,
values,
inverted,
operator,
contextName,
caseInsensitive,
}: IConstraint): IConstraint => {
const makeConstraint = (
valueProp: { value: string } | { values: string[] },
): IConstraint => {
return {
contextName,
operator,
...valueProp,
caseInsensitive,
inverted,
};
};
if (isSingleValueOperator(operator)) {
return makeConstraint({ value: value || '' });
}
return makeConstraint({ values: values || [] });
};
export const apiPayloadConstraintReplacer = (key: string, value: any) => {
if (key !== 'constraints' || !Array.isArray(value)) {
return value;
}
const orderedConstraints = (value as IConstraint[]).map(
serializeConstraint,
);
return orderedConstraints;
};

View File

@ -1,16 +1,15 @@
import { constraintId } from 'constants/constraintId';
import { dateOperators } from 'constants/operators';
import type { IConstraint } from 'interfaces/strategy';
import { oneOf } from 'utils/oneOf';
import { isDateOperator } from 'constants/operators';
import type { IConstraintWithId } from 'interfaces/strategy';
import { operatorsForContext } from 'utils/operatorsForContext';
import { v4 as uuidv4 } from 'uuid';
export const createEmptyConstraint = (contextName: string): IConstraint => {
export const createEmptyConstraint = (
contextName: string,
): IConstraintWithId => {
const operator = operatorsForContext(contextName)[0];
const value = oneOf(dateOperators, operator)
? new Date().toISOString()
: '';
const value = isDateOperator(operator) ? new Date().toISOString() : '';
return {
contextName,

View File

@ -2877,14 +2877,14 @@ __metadata:
linkType: hard
"@tanstack/react-virtual@npm:^3.11.3":
version: 3.13.9
resolution: "@tanstack/react-virtual@npm:3.13.9"
version: 3.13.10
resolution: "@tanstack/react-virtual@npm:3.13.10"
dependencies:
"@tanstack/virtual-core": "npm:3.13.9"
"@tanstack/virtual-core": "npm:3.13.10"
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
checksum: 10c0/aa05fb24e30686516e74ccdec94a83d195615a4f29bc866a53ee6b0107e466c6d6e3e947b3fb0613b907b0f982d74b00367868cf6b1ac956562d0e7c24d6764b
checksum: 10c0/587ef4db703cc9d870ee68b3f1471118fe69920e8f59cf627a359d518331c6684069fb3e159cd35ac458ea60a829b66953b774314833e1cb10b365255791559e
languageName: node
linkType: hard
@ -2895,10 +2895,10 @@ __metadata:
languageName: node
linkType: hard
"@tanstack/virtual-core@npm:3.13.9":
version: 3.13.9
resolution: "@tanstack/virtual-core@npm:3.13.9"
checksum: 10c0/6e9526a9d52f8ddc54af8a1dc7380814b10ab38d8a4265e362a5b69c3132097ace51496d4206fe8aa90e33129aaf1a177c93d7ed018b5564b78e057fc9cdb48d
"@tanstack/virtual-core@npm:3.13.10":
version: 3.13.10
resolution: "@tanstack/virtual-core@npm:3.13.10"
checksum: 10c0/ecfe56cc37db088416abb1f1b9641cc7b05b387bb532e4fe42f30a5477e55fdbb724f54e8dc5c3d8b380a5e9f80cc11426519825f8ecbcb6ca717639ba702cc8
languageName: node
linkType: hard
@ -3199,20 +3199,20 @@ __metadata:
linkType: hard
"@types/node@npm:*":
version: 22.13.10
resolution: "@types/node@npm:22.13.10"
version: 24.0.3
resolution: "@types/node@npm:24.0.3"
dependencies:
undici-types: "npm:~6.20.0"
checksum: 10c0/a3865f9503d6f718002374f7b87efaadfae62faa499c1a33b12c527cfb9fd86f733e1a1b026b80c5a0e4a965701174bc3305595a7d36078aa1abcf09daa5dee9
undici-types: "npm:~7.8.0"
checksum: 10c0/9c3c4e87600d1cf11e291c2fd4bfd806a615455463c30a0ef6dc9c801b3423344d9b82b8084e3ccabce485a7421ebb61a66e9676181bd7d9aea4759998a120d5
languageName: node
linkType: hard
"@types/node@npm:^22.0.0":
version: 22.15.18
resolution: "@types/node@npm:22.15.18"
version: 22.15.32
resolution: "@types/node@npm:22.15.32"
dependencies:
undici-types: "npm:~6.21.0"
checksum: 10c0/e23178c568e2dc6b93b6aa3b8dfb45f9556e527918c947fe7406a4c92d2184c7396558912400c3b1b8d0fa952ec63819aca2b8e4d3545455fc6f1e9623e09ca6
checksum: 10c0/63a2fa52adf1134d1b3bee8b1862d4b8e4550fffc190551068d3d41a41d9e5c0c8f1cb81faa18767b260637360f662115c26c5e4e7718868ead40c4a57cbc0e3
languageName: node
linkType: hard
@ -3313,10 +3313,10 @@ __metadata:
languageName: node
linkType: hard
"@types/semver@npm:7.5.8":
version: 7.5.8
resolution: "@types/semver@npm:7.5.8"
checksum: 10c0/8663ff927234d1c5fcc04b33062cb2b9fcfbe0f5f351ed26c4d1e1581657deebd506b41ff7fdf89e787e3d33ce05854bc01686379b89e9c49b564c4cfa988efa
"@types/semver@npm:7.7.0":
version: 7.7.0
resolution: "@types/semver@npm:7.7.0"
checksum: 10c0/6b5f65f647474338abbd6ee91a6bbab434662ddb8fe39464edcbcfc96484d388baad9eb506dff217b6fc1727a88894930eb1f308617161ac0f376fe06be4e1ee
languageName: node
linkType: hard
@ -3408,9 +3408,9 @@ __metadata:
languageName: node
linkType: hard
"@uiw/codemirror-extensions-basic-setup@npm:4.23.10":
version: 4.23.10
resolution: "@uiw/codemirror-extensions-basic-setup@npm:4.23.10"
"@uiw/codemirror-extensions-basic-setup@npm:4.23.13":
version: 4.23.13
resolution: "@uiw/codemirror-extensions-basic-setup@npm:4.23.13"
dependencies:
"@codemirror/autocomplete": "npm:^6.0.0"
"@codemirror/commands": "npm:^6.0.0"
@ -3427,22 +3427,22 @@ __metadata:
"@codemirror/search": ">=6.0.0"
"@codemirror/state": ">=6.0.0"
"@codemirror/view": ">=6.0.0"
checksum: 10c0/64c233857b1bf878bf630297b1d4b3df14e13761ff38ceccf7a1fa21d521be288311333b7cbff927889f9a899848f4fccefd975ea5fa8d626ea4aef088f13ee8
checksum: 10c0/db0d1c60c8b13f69aa02b969618d3b9f5aafab23af3c8d9be9a88016aff94de1e45a1c850daf7740d265a3e5452a916112f73e5c22857346a7002c243c934159
languageName: node
linkType: hard
"@uiw/codemirror-theme-duotone@npm:4.23.10":
version: 4.23.10
resolution: "@uiw/codemirror-theme-duotone@npm:4.23.10"
"@uiw/codemirror-theme-duotone@npm:4.23.13":
version: 4.23.13
resolution: "@uiw/codemirror-theme-duotone@npm:4.23.13"
dependencies:
"@uiw/codemirror-themes": "npm:4.23.10"
checksum: 10c0/058fe2ec927fb7d9fdd7a040c1fa52ed52b2b786e2917ae981a687b1aafeac26022900aac625c2018288c9bd7ab9e056854d6628eaaea46a4251b0022693df7c
"@uiw/codemirror-themes": "npm:4.23.13"
checksum: 10c0/3c0994d7731fdad1537d66f331b3bc1f4308e9388001b3a1d168c8e4b751f28661652533b0c830b3f30456132572686a298af0672f3f36872a08fdc1cf9b1e47
languageName: node
linkType: hard
"@uiw/codemirror-themes@npm:4.23.10":
version: 4.23.10
resolution: "@uiw/codemirror-themes@npm:4.23.10"
"@uiw/codemirror-themes@npm:4.23.13":
version: 4.23.13
resolution: "@uiw/codemirror-themes@npm:4.23.13"
dependencies:
"@codemirror/language": "npm:^6.0.0"
"@codemirror/state": "npm:^6.0.0"
@ -3451,19 +3451,19 @@ __metadata:
"@codemirror/language": ">=6.0.0"
"@codemirror/state": ">=6.0.0"
"@codemirror/view": ">=6.0.0"
checksum: 10c0/29f980789a535ae021ca8d8bec3ecee4dda8cb11c5451729c04481310cc04cd7c00ddb3011137f5cc305d565bc0a3464ebd88fdde9359e296d8f0a6cf6477811
checksum: 10c0/4e3bc3f12681727d41ae3c60c17fab5be267cb2675ace8ce6c56c66d6453715bf6d00c9b356d27df70f2104b0eaeda8c734faf2f05b38af05e7c5b2306f2a182
languageName: node
linkType: hard
"@uiw/react-codemirror@npm:4.23.10":
version: 4.23.10
resolution: "@uiw/react-codemirror@npm:4.23.10"
"@uiw/react-codemirror@npm:4.23.13":
version: 4.23.13
resolution: "@uiw/react-codemirror@npm:4.23.13"
dependencies:
"@babel/runtime": "npm:^7.18.6"
"@codemirror/commands": "npm:^6.1.0"
"@codemirror/state": "npm:^6.1.1"
"@codemirror/theme-one-dark": "npm:^6.0.0"
"@uiw/codemirror-extensions-basic-setup": "npm:4.23.10"
"@uiw/codemirror-extensions-basic-setup": "npm:4.23.13"
codemirror: "npm:^6.0.0"
peerDependencies:
"@babel/runtime": ">=7.11.0"
@ -3473,7 +3473,7 @@ __metadata:
codemirror: ">=6.0.0"
react: ">=16.8.0"
react-dom: ">=16.8.0"
checksum: 10c0/cbcb928c84df2a6a03fd7789a3db317f6358961fd96197d2a3edb3ffbb00deff1cfa05fa1494c849393784bb89ae3f41425982f609f73bad0855a5e6522c328d
checksum: 10c0/95e79e50fa1a28cbfaf982def6ff82c5ecf18fe7eca077b4bc4cba1d43580541fcb1c38effbd299acff97f77372d8bc910642adc3d6e610c5125a9f0b85f379e
languageName: node
linkType: hard
@ -8548,14 +8548,14 @@ __metadata:
languageName: node
linkType: hard
"react-activity-calendar@npm:^2.7.8":
version: 2.7.8
resolution: "react-activity-calendar@npm:2.7.8"
"react-activity-calendar@npm:^2.7.11":
version: 2.7.12
resolution: "react-activity-calendar@npm:2.7.12"
dependencies:
date-fns: "npm:^4.1.0"
peerDependencies:
react: ^18.0.0 || ^19.0.0
checksum: 10c0/e18d17cadc65480a8fa1df390598d7a4741a211b164091ca85fd2a21673fa667a5921f7b856e4ed92c2e8dedfb961426f163e517ea37336f2c3afb987740620d
checksum: 10c0/6e329c18d37e05c9f26efee3af229e9289744d9f6bac03a08b4911b62786a410d0f7cfe58ace1e52706da4adc250e82906a77ef6d2665e52d1e271edcdd8b60e
languageName: node
linkType: hard
@ -8630,14 +8630,14 @@ __metadata:
languageName: node
linkType: hard
"react-error-boundary@npm:^5.0.0":
version: 5.0.0
resolution: "react-error-boundary@npm:5.0.0"
"react-error-boundary@npm:^6.0.0":
version: 6.0.0
resolution: "react-error-boundary@npm:6.0.0"
dependencies:
"@babel/runtime": "npm:^7.12.5"
peerDependencies:
react: ">=16.13.1"
checksum: 10c0/38da5e7e81016a4750d3b090e3c740c2c1125c0bb9de14e1ab92ee3b5190d34517c199935302718a24aa35d3f89081412b3444edc23f63729bde2e862a2fbfec
checksum: 10c0/1914d600dee95a14f14af4afe9867b0d35c26c4f7826d23208800ba2a99728659029aad60a6ef95e13430b4d79c2c4c9b3585f50bf508450478760d2e4e732d8
languageName: node
linkType: hard
@ -8665,14 +8665,14 @@ __metadata:
linkType: hard
"react-github-calendar@npm:^4.5.1":
version: 4.5.6
resolution: "react-github-calendar@npm:4.5.6"
version: 4.5.7
resolution: "react-github-calendar@npm:4.5.7"
dependencies:
react-activity-calendar: "npm:^2.7.8"
react-error-boundary: "npm:^5.0.0"
react-activity-calendar: "npm:^2.7.11"
react-error-boundary: "npm:^6.0.0"
peerDependencies:
react: ^18.0.0 || ^19.0.0
checksum: 10c0/74e995a528a3cf2a4ac4634415b614a0f6ad170f066d2d58142f15b815e8855de8b2ecf426ea054dd5d29143185dedc9a4ec7a8fa4dea4cbad6b7ef0f4411b5a
checksum: 10c0/a274776d8f6547d342573a42618196f436d4e555a5eb84b7b43fda0350a5174959ae74bc9d230c8cdca1b8982e4f586583200292126d4aa05cb1e3d350b579d4
languageName: node
linkType: hard
@ -9241,12 +9241,12 @@ __metadata:
languageName: node
linkType: hard
"semver@npm:7.7.1":
version: 7.7.1
resolution: "semver@npm:7.7.1"
"semver@npm:7.7.2":
version: 7.7.2
resolution: "semver@npm:7.7.2"
bin:
semver: bin/semver.js
checksum: 10c0/fd603a6fb9c399c6054015433051bdbe7b99a940a8fb44b85c2b524c4004b023d7928d47cb22154f8d054ea7ee8597f586605e05b52047f048278e4ac56ae958
checksum: 10c0/aca305edfbf2383c22571cb7714f48cadc7ac95371b4b52362fb8eeffdfbc0de0669368b82b2b15978f8848f01d7114da65697e56cd8c37b0dab8c58e543f9ea
languageName: node
linkType: hard
@ -10221,13 +10221,6 @@ __metadata:
languageName: node
linkType: hard
"undici-types@npm:~6.20.0":
version: 6.20.0
resolution: "undici-types@npm:6.20.0"
checksum: 10c0/68e659a98898d6a836a9a59e6adf14a5d799707f5ea629433e025ac90d239f75e408e2e5ff086afc3cace26f8b26ee52155293564593fbb4a2f666af57fc59bf
languageName: node
linkType: hard
"undici-types@npm:~6.21.0":
version: 6.21.0
resolution: "undici-types@npm:6.21.0"
@ -10235,6 +10228,13 @@ __metadata:
languageName: node
linkType: hard
"undici-types@npm:~7.8.0":
version: 7.8.0
resolution: "undici-types@npm:7.8.0"
checksum: 10c0/9d9d246d1dc32f318d46116efe3cfca5a72d4f16828febc1918d94e58f6ffcf39c158aa28bf5b4fc52f410446bc7858f35151367bd7a49f21746cab6497b709b
languageName: node
linkType: hard
"unified@npm:^10.0.0":
version: 10.1.2
resolution: "unified@npm:10.1.2"
@ -10369,10 +10369,10 @@ __metadata:
"@types/react-router-dom": "npm:5.3.3"
"@types/react-table": "npm:7.7.20"
"@types/react-test-renderer": "npm:18.3.1"
"@types/semver": "npm:7.5.8"
"@types/semver": "npm:7.7.0"
"@types/uuid": "npm:^9.0.0"
"@uiw/codemirror-theme-duotone": "npm:4.23.10"
"@uiw/react-codemirror": "npm:4.23.10"
"@uiw/codemirror-theme-duotone": "npm:4.23.13"
"@uiw/react-codemirror": "npm:4.23.13"
"@unleash/proxy-client-react": "npm:^5.0.0"
"@vitejs/plugin-react": "npm:4.3.4"
cartesian: "npm:^1.0.1"
@ -10422,7 +10422,7 @@ __metadata:
react-table: "npm:7.8.0"
react-test-renderer: "npm:18.3.1"
sass: "npm:1.85.1"
semver: "npm:7.7.1"
semver: "npm:7.7.2"
swr: "npm:2.3.3"
tss-react: "npm:4.9.15"
typescript: "npm:5.8.3"

View File

@ -2,7 +2,7 @@
"name": "unleash-server",
"type": "module",
"description": "Unleash is an enterprise ready feature flag service. It provides different strategies for handling feature flags.",
"version": "7.0.1",
"version": "7.0.3",
"keywords": [
"unleash",
"feature flag",
@ -129,7 +129,7 @@
"ts-toolbelt": "^9.6.0",
"type-is": "^1.6.18",
"ulidx": "^2.4.1",
"unleash-client": "^6.6.0",
"unleash-client": "^6.7.0-beta.0",
"uuid": "^9.0.0"
},
"devDependencies": {
@ -141,25 +141,25 @@
"@swc/core": "1.11.31",
"@types/bcryptjs": "2.4.6",
"@types/cors": "2.8.19",
"@types/express": "4.17.21",
"@types/express-session": "1.18.1",
"@types/express": "4.17.23",
"@types/express-session": "1.18.2",
"@types/faker": "5.5.9",
"@types/hash-sum": "^1.0.0",
"@types/js-yaml": "4.0.9",
"@types/lodash.groupby": "4.6.9",
"@types/lodash.isequal": "^4.5.8",
"@types/memoizee": "0.4.11",
"@types/memoizee": "0.4.12",
"@types/mime": "4.0.0",
"@types/murmurhash3js": "^3.0.7",
"@types/mustache": "^4.2.5",
"@types/node": "22.15.18",
"@types/nodemailer": "6.4.17",
"@types/owasp-password-strength-test": "1.3.2",
"@types/pg": "8.15.2",
"@types/semver": "7.5.8",
"@types/pg": "8.15.4",
"@types/semver": "7.7.0",
"@types/slug": "^5.0.8",
"@types/stoppable": "1.1.3",
"@types/supertest": "6.0.2",
"@types/supertest": "6.0.3",
"@types/type-is": "1.6.7",
"@types/uuid": "9.0.8",
"@vitest/coverage-v8": "^3.1.3",
@ -167,7 +167,7 @@
"concurrently": "^9.0.0",
"copyfiles": "2.4.1",
"coveralls": "^3.1.1",
"del-cli": "5.1.0",
"del-cli": "6.0.0",
"faker": "5.5.3",
"fast-check": "3.23.2",
"fetch-mock": "^12.0.0",

View File

@ -11,7 +11,7 @@ services:
- 5432:5432
pgadmin:
image: dpage/pgadmin4:9.3
image: dpage/pgadmin4:9.4
environment:
PGADMIN_DEFAULT_EMAIL: 'admin@admin.com'
PGADMIN_DEFAULT_PASSWORD: 'admin'

View File

@ -101,6 +101,7 @@ exports[`should create default config 1`] = `
"preHook": undefined,
"preRouterHook": undefined,
"prometheusApi": undefined,
"prometheusImpactMetricsApi": undefined,
"publicFolder": undefined,
"rateLimiting": {
"callSignalEndpointMaxPerSecond": 1,

View File

@ -773,6 +773,10 @@ export function createConfig(options: IUnleashOptions): IUnleashConfig {
defaultDaysToBeConsideredInactive,
);
const prometheusImpactMetricsApi =
options.prometheusImpactMetricsApi ||
process.env.PROMETHEUS_IMPACT_METRICS_API;
return {
db,
session,
@ -804,6 +808,7 @@ export function createConfig(options: IUnleashOptions): IUnleashConfig {
clientFeatureCaching,
accessControlMaxAge,
prometheusApi,
prometheusImpactMetricsApi,
publicFolder: options.publicFolder,
disableScheduler: options.disableScheduler,
isEnterprise: isEnterprise,

View File

@ -71,35 +71,6 @@ export default class ClientInstanceStore implements IClientInstanceStore {
}
}
/**
* @deprecated
* `bulkUpsert` is beeing used instead. remove with `lastSeenBulkQuery` flag
*/
async setLastSeen({
appName,
instanceId,
environment,
clientIp,
}: INewClientInstance): Promise<void> {
const stopTimer = this.metricTimer('setLastSeen');
await this.db(TABLE)
.insert({
app_name: appName,
instance_id: instanceId,
environment,
last_seen: new Date(),
client_ip: clientIp,
})
.onConflict(['app_name', 'instance_id', 'environment'])
.merge({
last_seen: new Date(),
client_ip: clientIp,
});
stopTimer();
}
async bulkUpsert(instances: INewClientInstance[]): Promise<void> {
const stopTimer = this.metricTimer('bulkUpsert');

View File

@ -222,7 +222,7 @@ export default class EventService {
if (parsed) queryParams.push(parsed);
}
['project', 'type', 'environment'].forEach((field) => {
['project', 'type', 'environment', 'id'].forEach((field) => {
if (params[field]) {
const parsed = parseSearchOperatorValue(field, params[field]);
if (parsed) queryParams.push(parsed);

View File

@ -918,11 +918,6 @@ export default class ProjectFeaturesController extends Controller {
const { shouldActivateDisabledStrategies } = req.query;
const { features } = req.body;
if (this.flagResolver.isEnabled('disableBulkToggle')) {
res.status(403).end();
return;
}
await this.transactionalFeatureToggleService.transactional((service) =>
service.bulkUpdateEnabled(
projectId,
@ -950,11 +945,6 @@ export default class ProjectFeaturesController extends Controller {
const { shouldActivateDisabledStrategies } = req.query;
const { features } = req.body;
if (this.flagResolver.isEnabled('disableBulkToggle')) {
res.status(403).end();
return;
}
await this.transactionalFeatureToggleService.transactional((service) =>
service.bulkUpdateEnabled(
projectId,

View File

@ -6,7 +6,7 @@ import type {
IClientMetricsEnv,
IClientMetricsStoreV2,
} from './client-metrics-store-v2-type.js';
import { clientMetricsSchema } from '../shared/schema.js';
import { clientMetricsSchema, impactMetricsSchema } from '../shared/schema.js';
import { compareAsc, secondsToMilliseconds } from 'date-fns';
import {
CLIENT_METRICS,
@ -30,6 +30,11 @@ import {
MAX_UNKNOWN_FLAGS,
type UnknownFlagsService,
} from '../unknown-flags/unknown-flags-service.js';
import {
type Metric,
MetricsTranslator,
} from '../impact/metrics-translator.js';
import { impactRegister } from '../impact/impact-register.js';
export default class ClientMetricsServiceV2 {
private config: IUnleashConfig;
@ -46,6 +51,8 @@ export default class ClientMetricsServiceV2 {
private logger: Logger;
private impactMetricsTranslator: MetricsTranslator;
private cachedFeatureNames: () => Promise<string[]>;
constructor(
@ -69,6 +76,7 @@ export default class ClientMetricsServiceV2 {
maxAge: secondsToMilliseconds(10),
},
);
this.impactMetricsTranslator = new MetricsTranslator(impactRegister);
}
async clearMetrics(hoursAgo: number) {
@ -187,6 +195,11 @@ export default class ClientMetricsServiceV2 {
this.lastSeenService.updateLastSeen(metrics);
}
async registerImpactMetrics(impactMetrics: Metric[]) {
const value = await impactMetricsSchema.validateAsync(impactMetrics);
this.impactMetricsTranslator.translateMetrics(value);
}
async registerClientMetrics(
data: ClientMetricsSchema,
clientIp: string,

View File

@ -0,0 +1,103 @@
import {
type IUnleashTest,
setupAppWithCustomConfig,
} from '../../../../test/e2e/helpers/test-helper.js';
import dbInit, {
type ITestDb,
} from '../../../../test/e2e/helpers/database-init.js';
import getLogger from '../../../../test/fixtures/no-logger.js';
import type { Metric } from './metrics-translator.js';
let app: IUnleashTest;
let db: ITestDb;
const sendImpactMetrics = async (impactMetrics: Metric[], status = 202) =>
app.request
.post('/api/client/metrics')
.send({
appName: 'impact-metrics-app',
instanceId: 'instance-id',
bucket: {
start: Date.now(),
stop: Date.now(),
toggles: {},
},
impactMetrics,
})
.expect(status);
beforeAll(async () => {
db = await dbInit('impact_metrics', getLogger);
app = await setupAppWithCustomConfig(db.stores, {
experimental: {
flags: {
impactMetrics: true,
},
},
});
});
afterAll(async () => {
await app.destroy();
await db.destroy();
});
test('should store impact metrics in memory and be able to retrieve them', async () => {
await sendImpactMetrics([
{
name: 'labeled_counter',
help: 'with labels',
type: 'counter',
samples: [
{
labels: { foo: 'bar' },
value: 5,
},
],
},
]);
await sendImpactMetrics([
{
name: 'labeled_counter',
help: 'with labels',
type: 'counter',
samples: [
{
labels: { foo: 'bar' },
value: 10,
},
],
},
]);
await sendImpactMetrics([]);
// missing help
await sendImpactMetrics(
[
// @ts-expect-error
{
name: 'labeled_counter',
type: 'counter',
samples: [
{
labels: { foo: 'bar' },
value: 10,
},
],
},
],
400,
);
const response = await app.request
.get('/internal-backstage/impact/metrics')
.expect('Content-Type', /text/)
.expect(200);
const metricsText = response.text;
expect(metricsText).toContain('# HELP labeled_counter with labels');
expect(metricsText).toContain('# TYPE labeled_counter counter');
expect(metricsText).toMatch(/labeled_counter{foo="bar"} 15/);
});

View File

@ -1,11 +1,11 @@
import { Counter, Gauge, type Registry } from 'prom-client';
interface MetricSample {
export interface MetricSample {
labels?: Record<string, string | number>;
value: number;
}
interface Metric {
export interface Metric {
name: string;
help: string;
type: 'counter' | 'gauge';

View File

@ -101,21 +101,12 @@ export default class ClientInstanceService {
): Promise<void> {
const value = await clientMetricsSchema.validateAsync(data);
if (this.flagResolver.isEnabled('lastSeenBulkQuery')) {
this.seenClients[this.clientKey(value)] = {
appName: value.appName,
instanceId: value.instanceId,
environment: value.environment,
clientIp: clientIp,
};
} else {
await this.clientInstanceStore.setLastSeen({
appName: value.appName,
instanceId: value.instanceId,
environment: value.environment,
clientIp: clientIp,
});
}
this.seenClients[this.clientKey(value)] = {
appName: value.appName,
instanceId: value.instanceId,
environment: value.environment,
clientIp: clientIp,
};
}
public registerFrontendClient(data: IFrontendClientApp): void {

View File

@ -27,6 +27,7 @@ import { CLIENT_METRICS } from '../../../events/index.js';
import type { CustomMetricsSchema } from '../../../openapi/spec/custom-metrics-schema.js';
import type { StoredCustomMetric } from '../custom/custom-metrics-store.js';
import type { CustomMetricsService } from '../custom/custom-metrics-service.js';
import type { MetricsTranslator } from '../impact/metrics-translator.js';
export default class ClientMetricsController extends Controller {
logger: Logger;
@ -39,6 +40,8 @@ export default class ClientMetricsController extends Controller {
customMetricsService: CustomMetricsService;
metricsTranslator: MetricsTranslator;
flagResolver: IFlagResolver;
constructor(
@ -150,16 +153,25 @@ export default class ClientMetricsController extends Controller {
} else {
try {
const { body: data, ip: clientIp, user } = req;
data.environment = this.metricsV2.resolveMetricsEnvironment(
user,
data,
);
const { impactMetrics, ...metricsData } = data;
metricsData.environment =
this.metricsV2.resolveMetricsEnvironment(user, metricsData);
await this.clientInstanceService.registerInstance(
data,
metricsData,
clientIp,
);
await this.metricsV2.registerClientMetrics(data, clientIp);
await this.metricsV2.registerClientMetrics(
metricsData,
clientIp,
);
if (
this.flagResolver.isEnabled('impactMetrics') &&
impactMetrics
) {
await this.metricsV2.registerImpactMetrics(impactMetrics);
}
res.getHeaderNames().forEach((header) =>
res.removeHeader(header),
);

View File

@ -85,6 +85,35 @@ export const customMetricsSchema = joi
metrics: joi.array().items(customMetricSchema).required(),
});
export const metricSampleSchema = joi
.object()
.options({ stripUnknown: true })
.keys({
value: joi.number().required(),
labels: joi
.object()
.pattern(
joi.string(),
joi.alternatives().try(joi.string(), joi.number()),
)
.optional(),
});
export const impactMetricSchema = joi
.object()
.options({ stripUnknown: true })
.keys({
name: joi.string().required(),
help: joi.string().required(),
type: joi.string().required(),
samples: joi.array().items(metricSampleSchema).required(),
});
export const impactMetricsSchema = joi
.array()
.items(impactMetricSchema)
.empty();
export const batchMetricsSchema = joi
.object()
.options({ stripUnknown: true })

View File

@ -11,6 +11,17 @@ export const eventSearchQueryParameters = [
'Find events by a free-text search query. The query will be matched against the event data payload (if any).',
in: 'query',
},
{
name: 'id',
schema: {
type: 'string',
example: 'IS:123',
pattern: '^(IS|IS_ANY_OF):(.*?)(,([0-9]+))*$',
},
description:
'Filter by event ID using supported operators: IS, IS_ANY_OF.',
in: 'query',
},
{
name: 'feature',
schema: {

View File

@ -1,7 +1,7 @@
import stoppable, { type StoppableServer } from 'stoppable';
import { promisify } from 'util';
import version from './util/version.js';
import { migrateDb, resetDb } from '../migrator.js';
import { migrateDb, requiresMigration, resetDb } from '../migrator.js';
import getApp from './app.js';
import type MetricsMonitor from './metrics.js';
import { createMetricsMonitor } from './metrics.js';
@ -336,21 +336,25 @@ async function start(
if (config.db.disableMigration) {
logger.info('DB migration: disabled');
} else {
logger.info('DB migration: start');
if (config.flagResolver.isEnabled('migrationLock')) {
logger.info('Running migration with lock');
const lock = withDbLock(config.db, {
lockKey: defaultLockKey,
timeout: defaultTimeout,
logger,
});
await lock(migrateDb)(config);
} else {
logger.info('Running migration without lock');
await migrateDb(config);
}
if (await requiresMigration(config)) {
logger.info('DB migration: start');
if (config.flagResolver.isEnabled('migrationLock')) {
logger.info('Running migration with lock');
const lock = withDbLock(config.db, {
lockKey: defaultLockKey,
timeout: defaultTimeout,
logger,
});
await lock(migrateDb)(config);
} else {
logger.info('Running migration without lock');
await migrateDb(config);
}
logger.info('DB migration: end');
logger.info('DB migration: end');
} else {
logger.info('DB migration: no migration needed');
}
}
} catch (err) {
logger.error('Failed to migrate db', err);

View File

@ -52,7 +52,8 @@ const SCHEDULED_CHANGE_CONFLICT_SUBJECT =
'Unleash - Scheduled changes can no longer be applied';
const SCHEDULED_EXECUTION_FAILED_SUBJECT =
'Unleash - Scheduled change request could not be applied';
const REQUESTED_CR_APPROVAL_SUBJECT =
'Unleash - new change request waiting to be reviewed';
export const MAIL_ACCEPTED = '250 Accepted';
export type ChangeRequestScheduleConflictData =
@ -121,6 +122,67 @@ export class EmailService {
}
}
async sendRequestedCRApprovalEmail(
recipient: string,
changeRequestLink: string,
changeRequestTitle: string,
): Promise<IEmailEnvelope> {
if (this.configured()) {
const year = new Date().getFullYear();
const bodyHtml = await this.compileTemplate(
'requested-cr-approval',
TemplateFormat.HTML,
{
changeRequestLink,
changeRequestTitle,
year,
},
);
const bodyText = await this.compileTemplate(
'requested-cr-approval',
TemplateFormat.PLAIN,
{
changeRequestLink,
changeRequestTitle,
year,
},
);
const email = {
from: this.sender,
to: recipient,
subject: REQUESTED_CR_APPROVAL_SUBJECT,
html: bodyHtml,
text: bodyText,
};
process.nextTick(() => {
this.mailer!.sendMail(email).then(
() =>
this.logger.info(
'Successfully sent requested-cr-approval email',
),
(e) =>
this.logger.warn(
'Failed to send requested-cr-approval email',
e,
),
);
});
return Promise.resolve(email);
}
return new Promise((res) => {
this.logger.warn(
'No mailer is configured. Please read the docs on how to configure an email service',
);
this.logger.debug('Change request link: ', changeRequestLink);
res({
from: this.sender,
to: recipient,
subject: REQUESTED_CR_APPROVAL_SUBJECT,
html: '',
text: '',
});
});
}
async sendScheduledExecutionFailedEmail(
recipient: string,
changeRequestLink: string,

View File

@ -17,7 +17,6 @@ export type IFlagKey =
| 'migrationLock'
| 'demo'
| 'googleAuthEnabled'
| 'disableBulkToggle'
| 'advancedPlayground'
| 'filterInvalidClientMetrics'
| 'disableMetrics'
@ -57,7 +56,6 @@ export type IFlagKey =
| 'edgeObservability'
| 'registerFrontendClient'
| 'reportUnknownFlags'
| 'lastSeenBulkQuery'
| 'lifecycleMetrics'
| 'customMetrics'
| 'impactMetrics'
@ -105,10 +103,6 @@ const flags: IFlags = {
process.env.GOOGLE_AUTH_ENABLED,
false,
),
disableBulkToggle: parseEnvVarBoolean(
process.env.DISABLE_BULK_TOGGLE,
false,
),
filterInvalidClientMetrics: parseEnvVarBoolean(
process.env.FILTER_INVALID_CLIENT_METRICS,
false,
@ -272,10 +266,6 @@ const flags: IFlags = {
process.env.UNLEASH_EXPERIMENTAL_REPORT_UNKNOWN_FLAGS,
false,
),
lastSeenBulkQuery: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_LAST_SEEN_BULK_QUERY,
false,
),
lifecycleMetrics: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_LIFECYCLE_METRICS,
false,
@ -319,10 +309,19 @@ export interface IFlagResolver {
isEnabled: (expName: IFlagKey, context?: IFlagContext) => boolean;
getVariant: (expName: IFlagKey, context?: IFlagContext) => Variant;
getStaticContext: () => IFlagContext;
impactMetrics?: IImpactMetricsResolver;
}
export interface IExternalFlagResolver {
isEnabled: (flagName: IFlagKey, context?: IFlagContext) => boolean;
getVariant: (flagName: IFlagKey, context?: IFlagContext) => Variant;
getStaticContext: () => IFlagContext;
impactMetrics?: IImpactMetricsResolver;
}
export interface IImpactMetricsResolver {
defineCounter(name: string, help: string);
defineGauge(name: string, help: string);
incrementCounter(name: string, value?: number, featureName?: string): void;
updateGauge(name: string, value: number, featureName?: string): void;
}

View File

@ -164,6 +164,7 @@ export interface IUnleashOptions {
clientFeatureCaching?: Partial<IClientCachingOption>;
accessControlMaxAge?: number;
prometheusApi?: string;
prometheusImpactMetricsApi?: string;
publicFolder?: string;
disableScheduler?: boolean;
metricsRateLimiting?: Partial<IMetricsRateLimiting>;
@ -288,6 +289,7 @@ export interface IUnleashConfig {
clientFeatureCaching: IClientCachingOption;
accessControlMaxAge: number;
prometheusApi?: string;
prometheusImpactMetricsApi?: string;
publicFolder?: string;
disableScheduler?: boolean;
isEnterprise: boolean;

View File

@ -18,11 +18,6 @@ export interface IClientInstanceStore
Pick<INewClientInstance, 'appName' | 'instanceId'>
> {
bulkUpsert(instances: INewClientInstance[]): Promise<void>;
/**
* @deprecated
* `bulkUpsert` is beeing used instead. remove with `lastSeenBulkQuery` flag
*/
setLastSeen(INewClientInstance): Promise<void>;
insert(details: INewClientInstance): Promise<void>;
getByAppName(appName: string): Promise<IClientInstance[]>;
getRecentByAppNameAndEnvironment(

View File

@ -6,6 +6,7 @@ import type { IQueryOperations } from '../../features/events/event-store.js';
import type { IQueryParam } from '../../features/feature-toggle/types/feature-toggle-strategies-store-type.js';
export interface IEventSearchParams {
id?: string;
project?: string;
query?: string;
feature?: string;

View File

@ -0,0 +1,379 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>*|MC:SUBJECT|*</title>
<style type="text/css">
/* /\/\/\/\/\/\/\/\/ CLIENT-SPECIFIC STYLES /\/\/\/\/\/\/\/\/ */
#outlook a{padding:0;} /* Force Outlook to provide a "view in browser" message */
.ReadMsgBody{width:100%;} .ExternalClass{width:100%;} /* Force Hotmail to display emails at full width */
.ExternalClass, .ExternalClass p, .ExternalClass span, .ExternalClass font, .ExternalClass td, .ExternalClass div {line-height: 100%;} /* Force Hotmail to display normal line spacing */
body, table, td, p, a, li, blockquote{-webkit-text-size-adjust:100%; -ms-text-size-adjust:100%;} /* Prevent WebKit and Windows mobile changing default text sizes */
table, td{mso-table-lspace:0pt; mso-table-rspace:0pt;} /* Remove spacing between tables in Outlook 2007 and up */
img{-ms-interpolation-mode:bicubic;} /* Allow smoother rendering of resized image in Internet Explorer */
/* /\/\/\/\/\/\/\/\/ RESET STYLES /\/\/\/\/\/\/\/\/ */
body{margin:0; padding:0;}
img{border:0; height:auto; line-height:100%; outline:none; text-decoration:none;}
body, #bodyTable, #bodyCell{height:100% !important; margin:0; padding:0; width:100% !important;}
/* /\/\/\/\/\/\/\/\/ TEMPLATE STYLES /\/\/\/\/\/\/\/\/ */
/* ========== Page Styles ========== */
#bodyCell{padding:20px;}
#templateContainer{width:600px;}
/**
* @tab Page
* @section background style
* @tip Set the background color and top border for your email. You may want to choose colors that match your company's branding.
* @theme page
*/
body, #bodyTable{
background-color:#F7F7FA;
}
/**
* @tab Page
* @section email border
* @tip Set the border for your email.
*/
#templateContainer{
border:2px solid #817AFE;
border-radius: 12px;
overflow: hidden;
padding: 48px;
}
/**
* @tab Page
* @section heading 1
* @tip Set the styling for all first-level headings in your emails. These should be the largest of your headings.
* @style heading 1
*/
h1{
color:#202021 !important;
display:block;
font-family:Helvetica;
font-size:24px;
font-style:normal;
font-weight:bold;
line-height:1.4;
letter-spacing:normal;
margin-top:0;
margin-right:0;
margin-bottom:32px;
margin-left:0;
}
/**
* @tab Page
* @section heading 2
* @tip Set the styling for all second-level headings in your emails.
* @style heading 2
*/
h2{
color:#202021 !important;
display:block;
font-family:Helvetica;
font-size:20px;
font-style:normal;
font-weight:bold;
line-height:1.4;
letter-spacing:normal;
margin-top:0;
margin-right:0;
margin-bottom:24px;
margin-left:0;
text-align:left;
}
/**
* @tab Page
* @section heading 3
* @tip Set the styling for all third-level headings in your emails.
* @style heading 3
*/
h3{
color:#202021 !important;
display:block;
font-family:Helvetica;
font-size:16px;
font-style:normal;
font-weight:bold;
line-height:1.4;
letter-spacing:normal;
margin-top:0;
margin-right:0;
margin-bottom:16px;
margin-left:0;
text-align:left;
}
/**
* @tab Page
* @section heading 4
* @tip Set the styling for all fourth-level headings in your emails. These should be the smallest of your headings.
* @style heading 4
*/
h4{
color:#202021 !important;
display:block;
font-family:Helvetica;
font-size:14px;
font-style:normal;
font-weight:normal;
line-height:1.4;
letter-spacing:normal;
margin-top:0;
margin-right:0;
margin-bottom:16px;
margin-left:0;
text-align:left;
}
/* ========== Header Styles ========== */
/**
* @tab Header
* @section header style
* @tip Set the background color and borders for your email's header area.
* @theme header
*/
#templateHeader{
color:#6E6E70;
}
/**
* @tab Header
* @section header text
* @tip Set the styling for your email's header text. Choose a size and color that is easy to read.
*/
.headerContent{
color:#6E6E70;
font-family:Helvetica;
font-size:20px;
font-weight:bold;
line-height:1.4;
padding-top:0;
padding-right:0;
padding-bottom:0;
padding-left:0;
text-align:left;
vertical-align:middle;
}
/**
* @tab Header
* @section header link
* @tip Set the styling for your email's header links. Choose a color that helps them stand out from your text.
*/
.headerContent a:link, .headerContent a:visited, /* Yahoo! Mail Override */ .headerContent a .yshortcuts /* Yahoo! Mail Override */{
color:#fff;
font-weight:normal;
text-decoration:underline;
}
/* ========== Body Styles ========== */
/**
* @tab Body
* @section body style
* @tip Set the background color and borders for your email's body area.
*/
#templateBody{
margin: 48px 0;
padding: 48px 0;
border-top: 1px solid #E1E1E3;
border-bottom: 1px solid #E1E1E3;
}
/**
* @tab Body
* @section body text
* @tip Set the styling for your email's main content text. Choose a size and color that is easy to read.
* @theme main
*/
.bodyContent{
color:#202021;
font-family:Helvetica;
font-size:16px;
line-height:1.4;
text-align:left;
}
.bodyContent img{
display:inline;
height:auto;
max-width:560px;
}
.bodyContent a {
color: #615BC2;
text-decoration: none;
}
.bodyContent a:hover {
text-decoration: underline;
}
.bodyContent .buttonStyle {
margin-top: 32px;
display: inline-block;
padding: 13px 20px;
color: #fff;
background: #6C65E5;
border-radius: 4px;
font-size: 16px;
line-height: 1;
text-decoration: none !important;
}
/* ========== Footer Styles ========== */
/**
* @tab Footer
* @section footer text
* @tip Set the styling for your email's footer text. Choose a size and color that is easy to read.
* @theme footer
*/
.footerContent{
color:#6E6E70;
font-family:Helvetica;
font-size:14px;
line-height:1.4;
text-align:left;
}
.footerContent a {
padding-right: 12px;
margin-right: 12px;
border-right: 1px solid #E1E1E3;
color:#615BC2;
}
.footerContent em {
display: block;
margin-top: 24px;
}
/**
* @tab Footer
* @section footer link
* @tip Set the styling for your email's footer links. Choose a color that helps them stand out from your text.
*/
.footerContent a:link, .footerContent a:visited, /* Yahoo! Mail Override */ .footerContent a .yshortcuts, .footerContent a span /* Yahoo! Mail Override */{
color:#615BC2;
font-weight:normal;
text-decoration:underline;
}
/* /\/\/\/\/\/\/\/\/ MOBILE STYLES /\/\/\/\/\/\/\/\/ */
@media only screen and (max-width: 480px){
/* /\/\/\/\/\/\/ CLIENT-SPECIFIC MOBILE STYLES /\/\/\/\/\/\/ */
body, table, td, p, a, li, blockquote{-webkit-text-size-adjust:none !important;} /* Prevent Webkit platforms from changing default text sizes */
body{width:100% !important; min-width:100% !important;} /* Prevent iOS Mail from adding padding to the body */
/* /\/\/\/\/\/\/ MOBILE RESET STYLES /\/\/\/\/\/\/ */
#bodyCell{padding:10px !important;}
/* /\/\/\/\/\/\/ MOBILE TEMPLATE STYLES /\/\/\/\/\/\/ */
/* ======== Page Styles ======== */
/**
* @tab Mobile Styles
* @section template width
* @tip Make the template fluid for portrait or landscape view adaptability. If a fluid layout doesn't work for you, set the width to 300px instead.
*/
#templateContainer{
max-width:600px !important;
width:100% !important;
padding: 32px;
}
/* ======== Body Styles ======== */
#templateBody{
margin: 32px 0;
padding: 32px 0;
}
/* ======== Footer Styles ======== */
.footerContent a {
display:block !important;
border-right: 0;
padding: 6px 0;
} /* Place footer social and utility links on their own lines, for easier access */
}
</style>
</head>
<body leftmargin="0" marginwidth="0" topmargin="0" marginheight="0" offset="0">
<center>
<table align="center" border="0" cellpadding="0" cellspacing="0" height="100%" width="100%" id="bodyTable" style="background:#F7F7FA;">
<tr>
<td align="center" valign="top" id="bodyCell">
<!-- BEGIN TEMPLATE // -->
<table border="0" cellpadding="0" cellspacing="0" id="templateContainer" style="background:#FFFFFF;">
<tr>
<td align="center" valign="top">
<!-- BEGIN HEADER // -->
<table border="0" cellpadding="0" cellspacing="0" width="100%" id="templateHeader">
<tr>
<td valign="top" class="headerContent">
<img src="https://cdn.getunleash.io/unleash_logo_600.png" style="width:140px;" id="headerImage" mc:label="header_image" mc:edit="header_image" mc:allowdesigner mc:allowtext />
</td>
</tr>
</table>
<!-- // END HEADER -->
</td>
</tr>
<tr>
<td align="center" valign="top">
<!-- BEGIN BODY // -->
<table border="0" cellpadding="0" cellspacing="0" width="100%" id="templateBody">
<tr>
<td valign="top" class="bodyContent" mc:edit="body_content">
<h1>You have been added to review {{{ changeRequestTitle }}}</h1>
<p>Click <a class="changeRequestLink" href="{{{ changeRequestLink }}}" target="_blank" rel="noopener noreferrer">{{{changeRequestLink}}}</a> to review it</p>
</td>
</tr>
</table>
<!-- // END BODY -->
</td>
</tr>
<tr>
<td align="center" valign="top">
<!-- BEGIN FOOTER // -->
<table border="0" cellpadding="0" cellspacing="0" width="100%" id="templateFooter">
<tr>
<td valign="top" class="footerContent" mc:edit="footer_content00">
<a href="https://www.getunleash.io/blog">Unleash blog</a>
<a href="https://github.com/Unleash/unleash">Github</a>
<a href="https://slack.unleash.run">Slack community</a>
<a href="https://www.getunleash.io/support" style="border-right:0;">Help center</a>
</td>
</tr>
<tr>
<td valign="top" class="footerContent" mc:edit="footer_content01">
<em>Copyright &copy; {{ year }} | Bricks Software | All rights reserved.</em>
</td>
</tr>
</table>
<!-- // END FOOTER -->
</td>
</tr>
</table>
<!-- // END TEMPLATE -->
</td>
</tr>
</table>
</center>
</body>
</html>

View File

@ -0,0 +1,3 @@
You have been added to review {{{ changeRequestTitle }}}
Follow the link: {{{ changeRequestLink }}} to review it.

View File

@ -0,0 +1,19 @@
exports.up = function(db, cb) {
db.runSql(`CREATE TABLE change_request_requested_approvers(
change_request_id INTEGER REFERENCES change_requests(id) ON DELETE CASCADE,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
requested_at TIMESTAMP WITH TIME ZONE DEFAULT (now() at time zone 'utc'),
PRIMARY KEY (change_request_id, user_id)
);
CREATE INDEX IF NOT EXISTS change_request_requested_approvers_cr_id_idx ON change_request_requested_approvers(change_request_id);
CREATE INDEX IF NOT EXISTS change_request_requested_approvers_user_id_idx ON change_request_requested_approvers(user_id);
`, cb)
};
exports.down = function(db, cb) {
db.runSql(`DROP TABLE IF EXISTS change_request_requested_approvers`, cb);
};
exports._meta = {
"version": 1
};

View File

@ -0,0 +1,10 @@
exports.up = function(db, cb) {
db.runSql(`ALTER TABLE change_request_requested_approvers ADD COLUMN notified_at TIMESTAMP WITH TIME ZONE;
CREATE INDEX IF NOT EXISTS cr_req_approvers_notified_at_idx ON change_request_requested_approvers(notified_at);`, cb);
};
exports.down = function(db, cb) {
db.runSql(`
DROP INDEX IF EXISTS cr_req_approvers_notified_at_idx;
ALTER TABLE change_request_requested_approvers DROP COLUMN notified_at;`, cb);
};

View File

@ -40,6 +40,28 @@ export async function migrateDb(
});
}
export async function requiresMigration({
db,
}: Pick<IUnleashConfig, 'db'>): Promise<boolean> {
return noDatabaseUrl(async () => {
const custom = {
...db,
connectionTimeoutMillis: secondsToMilliseconds(10),
};
// disable Intellij/WebStorm from setting verbose CLI argument to db-migrator
process.argv = process.argv.filter((it) => !it.includes('--verbose'));
const dbm = getInstance(true, {
cwd: __dirname,
config: { custom },
env: 'custom',
});
const pendingMigrations = await dbm.check();
return pendingMigrations.length > 0;
});
}
// This exists to ease testing
export async function resetDb({ db }: IUnleashConfig): Promise<void> {
return noDatabaseUrl(async () => {

View File

@ -56,6 +56,7 @@ process.nextTick(async () => {
customMetrics: true,
lifecycleMetrics: true,
improvedJsonDiff: true,
impactMetrics: true,
},
},
authentication: {
@ -69,6 +70,7 @@ process.nextTick(async () => {
},
],
},
prometheusImpactMetricsApi: 'http://localhost:9090',
/* can be tweaked to control configuration caching for /api/client/features
clientFeatureCaching: {
enabled: true,

View File

@ -618,3 +618,103 @@ test('should filter events by environment using IS_ANY_OF', async () => {
total: 2,
});
});
test('should filter events by ID', async () => {
await eventService.storeEvent({
type: FEATURE_CREATED,
project: 'default',
data: { name: 'feature1' },
createdBy: 'test-user',
createdByUserId: TEST_USER_ID,
ip: '127.0.0.1',
});
await eventService.storeEvent({
type: FEATURE_CREATED,
project: 'default',
data: { name: 'feature2' },
createdBy: 'test-user',
createdByUserId: TEST_USER_ID,
ip: '127.0.0.1',
});
await eventService.storeEvent({
type: FEATURE_CREATED,
project: 'default',
data: { name: 'feature3' },
createdBy: 'test-user',
createdByUserId: TEST_USER_ID,
ip: '127.0.0.1',
});
const { body: allEventsResponse } = await searchEvents({});
const targetEvent = allEventsResponse.events.find(
(e: any) => e.data.name === 'feature2',
);
const { body } = await searchEvents({ id: `IS:${targetEvent.id}` });
expect(body).toMatchObject({
events: [
{
id: targetEvent.id,
type: 'feature-created',
data: { name: 'feature2' },
},
],
total: 1,
});
});
test('should filter events by multiple IDs using IS_ANY_OF', async () => {
await eventService.storeEvent({
type: FEATURE_CREATED,
project: 'default',
data: { name: 'feature1' },
createdBy: 'test-user',
createdByUserId: TEST_USER_ID,
ip: '127.0.0.1',
});
await eventService.storeEvent({
type: FEATURE_CREATED,
project: 'default',
data: { name: 'feature2' },
createdBy: 'test-user',
createdByUserId: TEST_USER_ID,
ip: '127.0.0.1',
});
await eventService.storeEvent({
type: FEATURE_CREATED,
project: 'default',
data: { name: 'feature3' },
createdBy: 'test-user',
createdByUserId: TEST_USER_ID,
ip: '127.0.0.1',
});
const { body: allEventsResponse } = await searchEvents({});
const targetEvent1 = allEventsResponse.events.find(
(e: any) => e.data.name === 'feature1',
);
const targetEvent3 = allEventsResponse.events.find(
(e: any) => e.data.name === 'feature3',
);
const { body } = await searchEvents({
id: `IS_ANY_OF:${targetEvent1.id},${targetEvent3.id}`,
});
expect(body.total).toBe(2);
expect(body.events).toHaveLength(2);
const returnedIds = body.events.map((e: any) => e.id);
expect(returnedIds).toContain(targetEvent1.id);
expect(returnedIds).toContain(targetEvent3.id);
const feature2Event = allEventsResponse.events.find(
(e: any) => e.data.name === 'feature2',
);
expect(returnedIds).not.toContain(feature2Event.id);
});

View File

@ -338,3 +338,137 @@ test('getMaxRevisionId should exclude FEATURE_CREATED and FEATURE_TAGGED events'
expect(updatedEvent!.id).toBeGreaterThan(taggedEvent!.id);
expect(segmentEvent!.id).toBeGreaterThan(updatedEvent!.id);
});
test('Should filter events by ID using IS operator', async () => {
const event1 = {
type: FEATURE_CREATED,
createdBy: 'test-user',
createdByUserId: TEST_USER_ID,
ip: '127.0.0.1',
data: { name: 'feature1' },
};
const event2 = {
type: FEATURE_CREATED,
createdBy: 'test-user',
createdByUserId: TEST_USER_ID,
ip: '127.0.0.1',
data: { name: 'feature2' },
};
const event3 = {
type: FEATURE_CREATED,
createdBy: 'test-user',
createdByUserId: TEST_USER_ID,
ip: '127.0.0.1',
data: { name: 'feature3' },
};
await eventStore.store(event1);
await eventStore.store(event2);
await eventStore.store(event3);
const allEvents = await eventStore.getAll();
const targetEvent = allEvents.find((e) => e.data.name === 'feature2');
const filteredEvents = await eventStore.searchEvents(
{
offset: 0,
limit: 10,
},
[
{
field: 'id',
operator: 'IS',
values: [targetEvent!.id.toString()],
},
],
);
expect(filteredEvents).toHaveLength(1);
expect(filteredEvents[0].id).toBe(targetEvent!.id);
expect(filteredEvents[0].data.name).toBe('feature2');
});
test('Should filter events by ID using IS_ANY_OF operator', async () => {
const event1 = {
type: FEATURE_CREATED,
createdBy: 'test-user',
createdByUserId: TEST_USER_ID,
ip: '127.0.0.1',
data: { name: 'feature1' },
};
const event2 = {
type: FEATURE_CREATED,
createdBy: 'test-user',
createdByUserId: TEST_USER_ID,
ip: '127.0.0.1',
data: { name: 'feature2' },
};
const event3 = {
type: FEATURE_CREATED,
createdBy: 'test-user',
createdByUserId: TEST_USER_ID,
ip: '127.0.0.1',
data: { name: 'feature3' },
};
await eventStore.store(event1);
await eventStore.store(event2);
await eventStore.store(event3);
const allEvents = await eventStore.getAll();
const targetEvent1 = allEvents.find((e) => e.data.name === 'feature1');
const targetEvent3 = allEvents.find((e) => e.data.name === 'feature3');
const filteredEvents = await eventStore.searchEvents(
{
offset: 0,
limit: 10,
},
[
{
field: 'id',
operator: 'IS_ANY_OF',
values: [
targetEvent1!.id.toString(),
targetEvent3!.id.toString(),
],
},
],
);
expect(filteredEvents).toHaveLength(2);
const eventIds = filteredEvents.map((e) => e.id);
expect(eventIds).toContain(targetEvent1!.id);
expect(eventIds).toContain(targetEvent3!.id);
expect(eventIds).not.toContain(
allEvents.find((e) => e.data.name === 'feature2')!.id,
);
});
test('Should return empty result when filtering by non-existent ID', async () => {
const event = {
type: FEATURE_CREATED,
createdBy: 'test-user',
createdByUserId: TEST_USER_ID,
ip: '127.0.0.1',
data: { name: 'feature1' },
};
await eventStore.store(event);
const filteredEvents = await eventStore.searchEvents(
{
offset: 0,
limit: 10,
},
[
{
field: 'id',
operator: 'IS',
values: ['999999'],
},
],
);
expect(filteredEvents).toHaveLength(0);
});

View File

@ -28,10 +28,6 @@ export default class FakeClientInstanceStore implements IClientInstanceStore {
);
}
setLastSeen(): Promise<void> {
return Promise.resolve();
}
async getBySdkName(sdkName: string): Promise<IClientInstance[]> {
return this.instances.filter((instance) =>
instance.sdkVersion?.startsWith(sdkName),

778
yarn.lock

File diff suppressed because it is too large Load Diff