Skip to content

Commit

Permalink
[Detector creation] UI workflow metrics (#865)
Browse files Browse the repository at this point in the history
* implemented metrics for detector creation

Signed-off-by: Amardeepsingh Siglani <amardeep7194@gmail.com>

* updated tests

Signed-off-by: Amardeepsingh Siglani <amardeep7194@gmail.com>

* added config based flag; interval of 2 min to emit browser metrics

Signed-off-by: Amardeepsingh Siglani <amardeep7194@gmail.com>

* removed unused metrics counters

Signed-off-by: Amardeepsingh Siglani <amardeep7194@gmail.com>

* added null check

Signed-off-by: Amardeepsingh Siglani <amardeep7194@gmail.com>

* updated code to check for window unload

Signed-off-by: Amardeepsingh Siglani <amardeep7194@gmail.com>

---------

Signed-off-by: Amardeepsingh Siglani <amardeep7194@gmail.com>
  • Loading branch information
amsiglan authored Feb 6, 2024
1 parent 7837a99 commit dff1ef3
Show file tree
Hide file tree
Showing 36 changed files with 632 additions and 883 deletions.
45 changes: 45 additions & 0 deletions common/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import _ from 'lodash';
import { DEFAULT_METRICS_COUNTER } from '../server/utils/constants';
import { MetricsCounter, PartialMetricsCounter } from '../types';
import { SecurityAnalyticsPluginConfigType } from '../config';

export function aggregateMetrics(
metrics: PartialMetricsCounter,
currentMetricsCounter: PartialMetricsCounter
): MetricsCounter {
const partialMetrics: PartialMetricsCounter = {
...currentMetricsCounter,
};
Object.keys(metrics).forEach((w) => {
const workflow = w as keyof MetricsCounter;
const workFlowMetrics = metrics[workflow];

if (workFlowMetrics) {
const counterToUpdate: any =
partialMetrics[workflow] || _.cloneDeep(DEFAULT_METRICS_COUNTER[workflow]);
Object.entries(workFlowMetrics).forEach(([metric, count]) => {
if (!counterToUpdate[metric]) {
counterToUpdate[metric] = 0;
}
counterToUpdate[metric] += count;
});

partialMetrics[workflow] = counterToUpdate;
}
});

return partialMetrics as MetricsCounter;
}

let securityAnalyticsPluginConfig: SecurityAnalyticsPluginConfigType;
export const setSecurityAnalyticsPluginConfig = (config: SecurityAnalyticsPluginConfigType) => {
securityAnalyticsPluginConfig = config;
};

export const getSecurityAnalyticsPluginConfig = (): SecurityAnalyticsPluginConfigType | undefined =>
securityAnalyticsPluginConfig;
15 changes: 15 additions & 0 deletions config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import { schema, TypeOf } from '@osd/config-schema';

export const configSchema = schema.object({
enabled: schema.boolean({ defaultValue: true }),
// Interval in minutes at which the browser should emit the metrics to the Kibana server
// Setting this to "0" will disable the metrics
uxTelemetryInterval: schema.number({ defaultValue: 2 }),
});

export type SecurityAnalyticsPluginConfigType = TypeOf<typeof configSchema>;
29 changes: 18 additions & 11 deletions public/components/Modal/Modal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,24 @@ import { EuiButton, EuiOverlayMask, EuiModal } from '@elastic/eui';
import { render, fireEvent } from '@testing-library/react';
import ModalRoot from './ModalRoot';
import { ModalConsumer, ModalProvider } from './Modal';
import { ServicesConsumer, ServicesContext } from '../../services';
import { SecurityAnalyticsContext, SaContextConsumer } from '../../services';
import services from '../../../test/mocks/services';
import { MetricsContext } from '../../metrics/MetricsContext';
import MetricsService from '../../services/MetricsService';
import httpClientMock from '../../../test/mocks/services/httpClient.mock';

describe('<ModalRoot /> spec', () => {
it('renders nothing when not used', () => {
const { container } = render(
<ServicesContext.Provider value={services}>
<SecurityAnalyticsContext.Provider
value={{ services, metrics: new MetricsContext(new MetricsService(httpClientMock)) }}
>
<ModalProvider>
<ServicesConsumer>
{(services) => services && <ModalRoot services={services} />}
</ServicesConsumer>
<SaContextConsumer>
{(context) => context?.services && <ModalRoot services={context?.services} />}
</SaContextConsumer>
</ModalProvider>
</ServicesContext.Provider>
</SecurityAnalyticsContext.Provider>
);

expect(container.firstChild).toBeNull();
Expand All @@ -34,11 +39,13 @@ describe('<ModalRoot /> spec', () => {
);
const { queryByText, getByTestId, getByLabelText } = render(
<div>
<ServicesContext.Provider value={services}>
<SecurityAnalyticsContext.Provider
value={{ services, metrics: new MetricsContext(new MetricsService(httpClientMock)) }}
>
<ModalProvider>
<ServicesConsumer>
{(services) => services && <ModalRoot services={services} />}
</ServicesConsumer>
<SaContextConsumer>
{(context) => context?.services && <ModalRoot services={context.services} />}
</SaContextConsumer>
<ModalConsumer>
{({ onShow }) => (
<EuiButton
Expand All @@ -50,7 +57,7 @@ describe('<ModalRoot /> spec', () => {
)}
</ModalConsumer>
</ModalProvider>
</ServicesContext.Provider>
</SecurityAnalyticsContext.Provider>
</div>
);

Expand Down
45 changes: 45 additions & 0 deletions public/metrics/DetectorMetricsManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import { CreateDetectorSteps, CreateDetectorStepValue } from '../../types';
import MetricsService from '../services/MetricsService';

export class DetectorMetricsManager {
private static initialCheckpoint = CreateDetectorStepValue[CreateDetectorSteps.notStarted];
private stepsLogged: number = DetectorMetricsManager.initialCheckpoint;
private creationStartedStepValue = CreateDetectorStepValue[CreateDetectorSteps.started];

constructor(private readonly metricsService: MetricsService) {}

public sendMetrics(step: CreateDetectorSteps, stepNameForCounter?: string) {
const stepValue = CreateDetectorStepValue[step];

// If we are not in detection creation flow, we should not emit any metric
if (
stepValue !== this.creationStartedStepValue &&
!this.metricEmittedForStep(this.creationStartedStepValue)
) {
return;
}

// Checks if we have already emitted metrics for this step
if (!this.metricEmittedForStep(stepValue)) {
this.metricsService.updateMetrics({
CreateDetector: {
[stepNameForCounter || step]: 1,
},
});
this.stepsLogged |= stepValue;
}
}

public resetMetrics() {
this.stepsLogged = DetectorMetricsManager.initialCheckpoint;
}

private metricEmittedForStep(stepValue: number): boolean {
return (this.stepsLogged & stepValue) === stepValue;
}
}
15 changes: 15 additions & 0 deletions public/metrics/MetricsContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import MetricsService from '../services/MetricsService';
import { DetectorMetricsManager } from './DetectorMetricsManager';

export class MetricsContext {
public detectorMetricsManager: DetectorMetricsManager;

constructor(metricsService: MetricsService) {
this.detectorMetricsManager = new DetectorMetricsManager(metricsService);
}
}
2 changes: 2 additions & 0 deletions public/models/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
LogTypeService,
} from '../services';
import CorrelationService from '../services/CorrelationService';
import MetricsService from '../services/MetricsService';

export interface BrowserServices {
detectorsService: DetectorsService;
Expand All @@ -31,6 +32,7 @@ export interface BrowserServices {
savedObjectsService: ISavedObjectsService;
indexPatternsService: IndexPatternsService;
logTypeService: LogTypeService;
metricsService: MetricsService;
}

export interface RuleOptions {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ interface AlertConditionPanelProps extends RouteComponentProps {
isEdit: boolean;
hasNotificationPlugin: boolean;
loadingNotifications: boolean;
onAlertTriggerChanged: (newDetector: Detector) => void;
onAlertTriggerChanged: (newDetector: Detector, emitMetrics?: boolean) => void;
refreshNotificationChannels: () => void;
}

Expand Down Expand Up @@ -76,7 +76,7 @@ export default class AlertConditionPanel extends Component<
}

componentDidMount() {
this.prepareMessage();
this.prepareMessage(false /* updateMessage */, true /* onMount */);
}

onDetectionTypeChange(detectionType: 'rules' | 'threat_intel', enabled: boolean) {
Expand All @@ -87,7 +87,10 @@ export default class AlertConditionPanel extends Component<
});
}

prepareMessage = (updateMessage: boolean = false) => {
// When component mounts, we prepare message but at this point we don't want to emit the
// trigger changed metric since it is not user initiated. So we use the onMount flag to determine that
// and pass it downstream accordingly.
prepareMessage = (updateMessage: boolean = false, onMount: boolean = false) => {
const { alertCondition, detector } = this.props;
const detectorInput = detector.inputs[0].detector_input;
const lineBreak = '\n';
Expand All @@ -101,7 +104,7 @@ export default class AlertConditionPanel extends Component<
const defaultSubject = [alertConditionName, alertConditionSeverity, detectorName].join(' - ');

if (updateMessage || !alertCondition.actions[0]?.subject_template.source)
this.onMessageSubjectChange(defaultSubject);
this.onMessageSubjectChange(defaultSubject, !onMount);

if (updateMessage || !alertCondition.actions[0]?.message_template.source) {
const selectedNames = this.setSelectedNames(alertCondition.ids);
Expand Down Expand Up @@ -142,11 +145,11 @@ export default class AlertConditionPanel extends Component<
if (alertConditionSelections.length)
defaultMessageBody =
defaultMessageBody + lineBreak + lineBreak + alertConditionSelections.join(lineBreak);
this.onMessageBodyChange(defaultMessageBody);
this.onMessageBodyChange(defaultMessageBody, !onMount);
}
};

updateTrigger(trigger: Partial<AlertCondition>) {
updateTrigger(trigger: Partial<AlertCondition>, emitMetrics: boolean = true) {
const {
alertCondition,
onAlertTriggerChanged,
Expand All @@ -157,7 +160,7 @@ export default class AlertConditionPanel extends Component<
trigger.types = [detector.detector_type.toLowerCase()];
const newTriggers = [...triggers];
newTriggers.splice(indexNum, 1, { ...alertCondition, ...trigger });
onAlertTriggerChanged({ ...detector, triggers: newTriggers });
onAlertTriggerChanged({ ...detector, triggers: newTriggers }, emitMetrics);
}

onNameBlur = (event: React.ChangeEvent<HTMLInputElement>) => {
Expand Down Expand Up @@ -220,21 +223,21 @@ export default class AlertConditionPanel extends Component<
onAlertTriggerChanged({ ...detector, triggers: triggers });
};

onMessageSubjectChange = (subject: string) => {
onMessageSubjectChange = (subject: string, emitMetrics: boolean = true) => {
const {
alertCondition: { actions },
} = this.props;
actions[0].name = subject;
actions[0].subject_template.source = subject;
this.updateTrigger({ actions: actions });
this.updateTrigger({ actions: actions }, emitMetrics);
};

onMessageBodyChange = (message: string) => {
onMessageBodyChange = (message: string, emitMetrics: boolean = true) => {
const {
alertCondition: { actions },
} = this.props;
actions[0].message_template.source = message;
this.updateTrigger({ actions: actions });
this.updateTrigger({ actions: actions }, emitMetrics);
};

onDelete = () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,13 @@ import { NotificationsService } from '../../../../../services';
import { validateName } from '../../../../../utils/validation';
import { CoreServicesContext } from '../../../../../components/core_services';
import { BREADCRUMBS } from '../../../../../utils/constants';
import { AlertCondition, Detector, DetectorCreationStep } from '../../../../../../types';
import {
AlertCondition,
CreateDetectorSteps,
Detector,
DetectorCreationStep,
} from '../../../../../../types';
import { MetricsContext } from '../../../../../metrics/MetricsContext';

interface ConfigureAlertsProps extends RouteComponentProps {
detector: Detector;
Expand All @@ -38,6 +44,7 @@ interface ConfigureAlertsProps extends RouteComponentProps {
notificationsService: NotificationsService;
hasNotificationPlugin: boolean;
getTriggerName: () => string;
metricsContext?: MetricsContext;
}

interface ConfigureAlertsState {
Expand Down Expand Up @@ -114,7 +121,8 @@ export default class ConfigureAlerts extends Component<ConfigureAlertsProps, Con
getNotificationChannels = async () => {
this.setState({ loading: true });
const channels = await getNotificationChannels(this.props.notificationsService);
this.setState({ notificationChannels: parseNotificationChannelsToOptions(channels) });
const parsedChannels = parseNotificationChannelsToOptions(channels);
this.setState({ notificationChannels: parsedChannels });
this.setState({ loading: false });
};

Expand All @@ -134,13 +142,18 @@ export default class ConfigureAlerts extends Component<ConfigureAlertsProps, Con
changeDetector({ ...detector, triggers: newTriggers });
};

onAlertTriggerChanged = (newDetector: Detector): void => {
onAlertTriggerChanged = (newDetector: Detector, emitMetrics: boolean = true): void => {
const isTriggerDataValid = isTriggerValid(
newDetector.triggers,
this.props.hasNotificationPlugin
);
this.props.changeDetector(newDetector);
this.props.updateDataValidState(DetectorCreationStep.CONFIGURE_ALERTS, isTriggerDataValid);
if (emitMetrics) {
this.props.metricsContext?.detectorMetricsManager.sendMetrics(
CreateDetectorSteps.triggerConfigured
);
}
};

onDelete = (index: number) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@ import { GetFieldMappingViewResponse } from '../../../../../../server/models/int
import FieldMappingService from '../../../../../services/FieldMappingService';
import { MappingViewType } from '../components/RequiredFieldMapping/FieldMappingsTable';
import { CreateDetectorRulesState } from '../../DefineDetector/components/DetectionRules/DetectionRules';
import { Detector } from '../../../../../../types';
import {
CreateDetectorSteps,
Detector,
SecurityAnalyticsContextType,
} from '../../../../../../types';
import { SecurityAnalyticsContext } from '../../../../../services';

export interface ruleFieldToIndexFieldMap {
[fieldName: string]: string;
Expand Down Expand Up @@ -59,6 +64,10 @@ export default class ConfigureFieldMapping extends Component<
ConfigureFieldMappingProps,
ConfigureFieldMappingState
> {
public static contextType?:
| React.Context<SecurityAnalyticsContextType | null>
| undefined = SecurityAnalyticsContext;

constructor(props: ConfigureFieldMappingProps) {
super(props);
const createdMappings: ruleFieldToIndexFieldMap = {};
Expand Down Expand Up @@ -310,6 +319,9 @@ export default class ConfigureFieldMapping extends Component<
invalidMappingFieldNames: invalidMappingFieldNames,
});
this.updateMappingSharedState(newMappings);
this.context.metrics.detectorMetricsManager.sendMetrics(
CreateDetectorSteps.fieldMappingsConfigured
);
};

updateMappingSharedState = (createdMappings: ruleFieldToIndexFieldMap) => {
Expand Down
Loading

0 comments on commit dff1ef3

Please sign in to comment.