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;
}