From e82ce64b03a5702880a779aa9d86f57ac0c1f585 Mon Sep 17 00:00:00 2001 From: Dayan Paez Date: Mon, 30 Oct 2023 19:41:59 +0000 Subject: [PATCH] feat: emit metrics on pipeline health (on by default) --- API.md | 75 +++++++++++++++++++++ src/DeploymentSafetyEnforcer.function.ts | 86 +++++++++++++++++++++++- src/DeploymentSafetyEnforcer.ts | 48 +++++++++++++ src/common.ts | 31 +++++++++ 4 files changed, 239 insertions(+), 1 deletion(-) diff --git a/API.md b/API.md index c11b126..fdf1080 100644 --- a/API.md +++ b/API.md @@ -516,6 +516,7 @@ const deploymentSafetyEnforcerProps: DeploymentSafetyEnforcerProps = { ... } | bakeSteps | {[ key: string ]: BakeStepProps} | Bake step configurations, indexed by manual approval action name. | | changeCalendars | {[ key: string ]: string[]} | SSM Change Calendars to consult for promotions into a given stage. | | enforcementFrequency | aws-cdk-lib.Duration | How often to run the enforcer. | +| metrics | MetricsProps | Configuration for emitting pipeline metrics. | --- @@ -573,5 +574,79 @@ Default: 10 minutes. --- +##### `metrics`Optional + +```typescript +public readonly metrics: MetricsProps; +``` + +- *Type:* MetricsProps + +Configuration for emitting pipeline metrics. + +--- + +### MetricsProps + +Props for emitting pipeline-related metrics. + +#### Initializer + +```typescript +import { MetricsProps } from 'cdk-deployment-constructs' + +const metricsProps: MetricsProps = { ... } +``` + +#### Properties + +| **Name** | **Type** | **Description** | +| --- | --- | --- | +| enabled | boolean | Set to true to emit metrics on pipeline with every enforcer execution. | +| extraMetricDimensions | {[ key: string ]: string} | Cloudwatch dimensions to use for the metrics. Metrics are always published with no dimensions. | +| metricNamespace | string | CloudWatch namespace to use for the metrics. | + +--- + +##### `enabled`Required + +```typescript +public readonly enabled: boolean; +``` + +- *Type:* boolean + +Set to true to emit metrics on pipeline with every enforcer execution. + +Default: true + +--- + +##### `extraMetricDimensions`Optional + +```typescript +public readonly extraMetricDimensions: {[ key: string ]: string}; +``` + +- *Type:* {[ key: string ]: string} + +Cloudwatch dimensions to use for the metrics. Metrics are always published with no dimensions. + +Default: 'PipelineName' with value set to pipeline's name + +--- + +##### `metricNamespace`Optional + +```typescript +public readonly metricNamespace: string; +``` + +- *Type:* string + +CloudWatch namespace to use for the metrics. + +--- + diff --git a/src/DeploymentSafetyEnforcer.function.ts b/src/DeploymentSafetyEnforcer.function.ts index 5be8e15..3e8cf00 100644 --- a/src/DeploymentSafetyEnforcer.function.ts +++ b/src/DeploymentSafetyEnforcer.function.ts @@ -4,6 +4,7 @@ import { BakeStepAlarmSettings, BakeStepSettings, DeploymentSafetySettings, + MetricsSettings, } from './common'; interface DisableReason { @@ -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```'; @@ -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; @@ -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!, @@ -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 ( diff --git a/src/DeploymentSafetyEnforcer.ts b/src/DeploymentSafetyEnforcer.ts index 9c0103f..c7b7a2c 100644 --- a/src/DeploymentSafetyEnforcer.ts +++ b/src/DeploymentSafetyEnforcer.ts @@ -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; +} + /** * Properties for `DeploymentSafetyEnforcer`. */ @@ -75,6 +100,12 @@ export interface DeploymentSafetyEnforcerProps { */ readonly bakeSteps?: Record; + + /** + * Configuration for emitting pipeline metrics. + */ + readonly metrics?: MetricsProps; + /** * How often to run the enforcer. * @@ -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 = {}; Object.entries(bakeSteps).forEach(([actionName, settings]) => { bakeStepSettings[actionName] = { @@ -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', { diff --git a/src/common.ts b/src/common.ts index d7c6156..70bb892 100644 --- a/src/common.ts +++ b/src/common.ts @@ -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; +} + export interface DeploymentSafetySettings { /** * Name of the CodePipeline associated with the settings. @@ -57,4 +83,9 @@ export interface DeploymentSafetySettings { * Bake time steps indexed by unique action name. */ readonly bakeSteps: Record; + + /** + * Customize the metrics emitted. + */ + readonly metricsSettings: MetricsSettings; }