Skip to content

Commit

Permalink
feat: emit metrics on pipeline health (on by default)
Browse files Browse the repository at this point in the history
  • Loading branch information
Dayan Paez committed Oct 30, 2023
1 parent 7680da3 commit e82ce64
Show file tree
Hide file tree
Showing 4 changed files with 239 additions and 1 deletion.
75 changes: 75 additions & 0 deletions API.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

86 changes: 85 additions & 1 deletion src/DeploymentSafetyEnforcer.function.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
BakeStepAlarmSettings,
BakeStepSettings,
DeploymentSafetySettings,
MetricsSettings,
} from './common';

interface DisableReason {
Expand Down Expand Up @@ -37,6 +38,7 @@ interface BakeStepAction {

const codepipeline = new aws.CodePipeline();
const ssm = new aws.SSM();
const cloudwatch = new aws.CloudWatch();

const toMarkdown = (reason: DisableReason) =>
'```\n' + JSON.stringify(reason, null, 2) + '\n```';
Expand Down Expand Up @@ -88,6 +90,72 @@ const transitionDisabledByEnforcer = (reason?: string) => {
return true;
};

interface EmitMetricsArgs {
readonly pipelineState: aws.CodePipeline.GetPipelineStateOutput;
readonly requestId: string;
readonly rejectedBakeActions: number;
readonly settings: MetricsSettings;
}

const emitMetrics = async (args: EmitMetricsArgs) => {
const dimensions = Object.entries(args.settings.dimensionsMap ?? {})
.map(
([name, value]) => ({
Name: name,
Value: value,
}));

const stages = (args.pipelineState.stageStates ?? []);
const actions = stages.flatMap((stage) => stage.actionStates ?? []);
const failedStages = actions
.filter((action) => action?.latestExecution?.status === 'Failed')
.length;

const metricData: aws.CloudWatch.MetricDatum[] = [
{
MetricName: 'FailedStages',
Value: failedStages + args.rejectedBakeActions,
Unit: 'Count',
},
];

if (actions.length > 1) {
let earliestActionExecution: Date | undefined;
let latestActionExecution: Date | undefined;
for (const action of actions) {
if (action.latestExecution?.lastStatusChange) {
const time = action.latestExecution!.lastStatusChange!;
if (earliestActionExecution === undefined || time < earliestActionExecution) {
earliestActionExecution = time;
}
if (latestActionExecution === undefined || time < latestActionExecution) {
latestActionExecution = time;
}
}
}

if (earliestActionExecution && latestActionExecution) {
metricData.push({
MetricName: 'Max',
Value: (latestActionExecution.getTime() - earliestActionExecution.getTime()) / 1000,
Unit: 'Seconds',
});
}
}

await cloudwatch.putMetricData({
Namespace: args.settings.namespace!,
MetricData: [
...metricData,
// create a copy with actual dimensions
...metricData.map((datum) => ({
...datum,
Dimensions: dimensions,
})),
],
}).promise();
};

interface AlarmStateQuery {
readonly alarmName: string;
readonly startTime: Date;
Expand Down Expand Up @@ -431,8 +499,9 @@ const execute = async (
);

// second: reject/approve all bake times
const rejectBakeActions = bakeActions.filter((a) => a.decision === 'REJECT');
await Promise.all(
bakeActions.filter((a) => a.decision === 'REJECT').map(({ approvalProps, rejectReasons }) =>
rejectBakeActions.map(({ approvalProps, rejectReasons }) =>
codepipeline.putApprovalResult({
actionName: approvalProps!.actionName!,
pipelineName: approvalProps!.pipelineName!,
Expand Down Expand Up @@ -472,6 +541,21 @@ const execute = async (
.promise(),
),
);

// emit metrics: should be at the end
if (settings.metricsSettings?.enabled === true) {
await emitMetrics({
pipelineState,
requestId,
rejectedBakeActions: rejectBakeActions.length,
settings: settings.metricsSettings ?? {
namespace: 'DeploymentSafetyEnforcer',
dimensionsMap: {
PipelineName: settings.pipelineName,
},
},
});
}
};

export const handler = async (
Expand Down
48 changes: 48 additions & 0 deletions src/DeploymentSafetyEnforcer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,31 @@ export interface BakeStepProps {
readonly rejectOnAlarms?: BakeStepAlarmProps[];
}

/**
* Props for emitting pipeline-related metrics.
*/
export interface MetricsProps {
/**
* Set to true to emit metrics on pipeline with every enforcer execution.
*
* Default: true
*/
readonly enabled: boolean;

/**
* CloudWatch namespace to use for the metrics.
*/
readonly metricNamespace?: string;

/**
* Cloudwatch dimensions to use for the metrics. Metrics are always published
* with no dimensions.
*
* Default: 'PipelineName' with value set to pipeline's name
*/
readonly extraMetricDimensions?: Record<string, string>;
}

/**
* Properties for `DeploymentSafetyEnforcer`.
*/
Expand All @@ -75,6 +100,12 @@ export interface DeploymentSafetyEnforcerProps {
*/
readonly bakeSteps?: Record<string, BakeStepProps>;


/**
* Configuration for emitting pipeline metrics.
*/
readonly metrics?: MetricsProps;

/**
* How often to run the enforcer.
*
Expand Down Expand Up @@ -192,6 +223,18 @@ export class DeploymentSafetyEnforcer extends Construct {
}
}

if (props.metrics?.enabled) {
enforcerFunction.addToRolePolicy(
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: [
'cloudwatch:PutMetricData',
],
resources: ['*'], // IAM requires this level for given operation
}),
);
}

const bakeStepSettings: Record<string, common.BakeStepSettings> = {};
Object.entries(bakeSteps).forEach(([actionName, settings]) => {
bakeStepSettings[actionName] = {
Expand All @@ -209,6 +252,11 @@ export class DeploymentSafetyEnforcer extends Construct {
pipelineName: props.pipeline.pipelineName,
changeCalendars: props.changeCalendars ?? {},
bakeSteps: bakeStepSettings,
metricsSettings: {
enabled: props.metrics?.enabled !== false,
namespace: props.metrics?.metricNamespace,
dimensionsMap: props.metrics?.extraMetricDimensions,
},
};

new events.Rule(this, 'ScheduleEnforcer', {
Expand Down
31 changes: 31 additions & 0 deletions src/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,32 @@ export interface BakeStepSettings {
readonly alarmSettings?: BakeStepAlarmSettings[];
}

/**
* Settings for pipeline-health metrics.
*/
export interface MetricsSettings {
/**
* Set to true to emit metrics on pipeline with every enforcer execution.
*
* Default: true
*/
readonly enabled: boolean;

/**
* CloudWatch namespace to use for the metrics.
*
* Default: 'DeploymentSafetyEnforcer'
*/
readonly namespace?: string;

/**
* Cloudwatch dimensions to use for the metrics.
*
* Default: 'PipelineName'
*/
readonly dimensionsMap?: Record<string, string>;
}

export interface DeploymentSafetySettings {
/**
* Name of the CodePipeline associated with the settings.
Expand All @@ -57,4 +83,9 @@ export interface DeploymentSafetySettings {
* Bake time steps indexed by unique action name.
*/
readonly bakeSteps: Record<string, BakeStepSettings>;

/**
* Customize the metrics emitted.
*/
readonly metricsSettings: MetricsSettings;
}

0 comments on commit e82ce64

Please sign in to comment.