Repository: aws-cloudformation/rain (go)

Issue:
Add analytics data
We can record usage analytics by adding this to templates generated by Rain:

```yaml
Metadata:
  AWSToolsMetrics:
    Rain: {string - whatever we want}
```

This will be recorded by the CloudFormation service.

This should be implemented by the deploy command, which will add the metadata node silently, unless the user specifies the `--no-analytics` flag.

Data to record:
- Rain version
- Modules used? This would be hard, since modules could be local paths, uris, zipped packages. It's not like CDK with standard libraries. If we do this, it will have to be done by the pkg command too.
- Features used? Modules, metadata

Maybe the version is all we need?

Design the data model to be flexible in case we want to add new elements later. Maybe a base64 encoded and compressed yaml string.

Next question is, if all we add is the version, what does this give us over existing analytics based on SDK calls with the user agent?

Pull request:
Add analytics to template Metadata
Fixes #584 

Patch:
diff --git a/cft/cft.go b/cft/cft.go
index fa5ec51f..4c33b3e4 100644
--- a/cft/cft.go
+++ b/cft/cft.go
@@ -143,7 +143,7 @@ func (t Template) GetSection(section Section) (*yaml.Node, error) {
 	m := t.Node.Content[0]
 	_, s, _ := s11n.GetMapValue(m, string(section))
 	if s == nil {
-		return nil, fmt.Errorf("unable to locate the %s node", section)
+		return nil, fmt.Errorf("unable to locate the %s section", section)
 	}
 	return s, nil
 }
diff --git a/cft/pkg/module.go b/cft/pkg/module.go
index 9bd51465..1b8b19cf 100644
--- a/cft/pkg/module.go
+++ b/cft/pkg/module.go
@@ -16,6 +16,8 @@ import (
 	"gopkg.in/yaml.v3"
 )
 
+var HasModules bool
+
 const (
 	Rain                = "Rain"
 	Metadata            = "Metadata"
@@ -795,6 +797,8 @@ func module(ctx *directiveContext) (bool, error) {
 		return false, errors.New("expected !Rain::Module <URI>")
 	}
 
+	HasModules = true
+
 	uri := n.Content[1].Value
 	var content []byte
 	var err error
diff --git a/cft/pkg/pkg.go b/cft/pkg/pkg.go
index 4fba2074..61d97607 100644
--- a/cft/pkg/pkg.go
+++ b/cft/pkg/pkg.go
@@ -26,6 +26,7 @@ package pkg
 
 import (
 	"embed"
+	"encoding/json"
 	"errors"
 	"fmt"
 	"path/filepath"
@@ -44,6 +45,22 @@ import (
 
 // Experimental must be set to true to enable !Rain::Module
 var Experimental bool
+var NoAnalytics bool
+var HasRainSection bool
+
+type analytics struct {
+	// Current Rain version
+	Version string
+
+	// Are we using experimental features?
+	Experimental bool
+
+	// Did we use any Rain modules?
+	HasModules bool
+
+	// Did the template have a Rain section?
+	HasRainSection bool
+}
 
 type transformContext struct {
 	nodeToTransform *yaml.Node
@@ -154,6 +171,8 @@ func processRainSection(t *cft.Template) bool {
 	// Now remove the Rain node from the template
 	t.RemoveSection(cft.Rain)
 
+	HasRainSection = true
+
 	return true
 }
 
@@ -265,7 +284,27 @@ func Template(t cft.Template, rootDir string, fs *embed.FS) (cft.Template, error
 		retval.Node.Content = append(retval.Node.Content, templateNode)
 	}
 
-	return retval, err
+	// Add analytics to Metadata
+	if !NoAnalytics {
+		metadata, err := retval.GetSection(cft.Metadata)
+		if err != nil || metadata == nil {
+			metadata = node.AddMap(retval.Node.Content[0], string(cft.Metadata))
+		}
+		awsToolsMetrics := node.AddMap(metadata, "AWSToolsMetrics")
+		a := analytics{
+			Version:        config.VERSION,
+			HasModules:     HasModules,
+			Experimental:   Experimental,
+			HasRainSection: HasRainSection,
+		}
+		s, _ := json.Marshal(&a)
+		awsToolsMetrics.Content = append(awsToolsMetrics.Content,
+			&yaml.Node{Kind: yaml.ScalarNode, Value: "Rain"})
+		awsToolsMetrics.Content = append(awsToolsMetrics.Content,
+			&yaml.Node{Kind: yaml.ScalarNode, Value: string(s)})
+	}
+
+	return retval, nil
 }
 
 // File opens path as a CloudFormation template and returns a cft.Template
diff --git a/internal/cmd/deploy/deploy.go b/internal/cmd/deploy/deploy.go
index 078389ec..0c7dee35 100644
--- a/internal/cmd/deploy/deploy.go
+++ b/internal/cmd/deploy/deploy.go
@@ -153,7 +153,7 @@ To list and delete changesets, use the ls and rm commands.
 				panic(err)
 			}
 
-			// Figure out how long we thing the stack will take to execute
+			// Figure out how long we think the stack will take to execute
 			//totalSeconds := forecast.PredictTotalEstimate(template, stackExists)
 			// TODO - Wait until the forecast command is GA and add this to output
 
@@ -339,4 +339,5 @@ func init() {
 	Cmd.Flags().StringVar(&format.NodeStyle, "node-style", "original", format.NodeStyleDocs)
 	Cmd.Flags().BoolVar(&experimental, "experimental", false, "Acknowledge that you want to deploy with an experimental feature")
 	Cmd.Flags().BoolVar(&includeNested, "nested-change-set", true, "Whether or not to include nested stacks in the change set")
+	Cmd.Flags().BoolVar(&cftpkg.NoAnalytics, "no-analytics", false, "Do not write analytics to Metadata")
 }
diff --git a/internal/cmd/pkg/pkg.go b/internal/cmd/pkg/pkg.go
index 75161c17..8a1ece9c 100644
--- a/internal/cmd/pkg/pkg.go
+++ b/internal/cmd/pkg/pkg.go
@@ -93,4 +93,5 @@ func init() {
 	Cmd.Flags().BoolVar(&config.Debug, "debug", false, "Output debugging information")
 	Cmd.Flags().BoolVar(&dataModel, "datamodel", false, "Output the go yaml data model")
 	Cmd.Flags().StringVar(&format.NodeStyle, "node-style", "", format.NodeStyleDocs)
+	Cmd.Flags().BoolVar(&cftpkg.NoAnalytics, "no-analytics", false, "Do not include analytics in Metadata")
 }


Test patch:
diff --git a/cft/pkg/module_test.go b/cft/pkg/module_test.go
index 70ecf82a..5f374acd 100644
--- a/cft/pkg/module_test.go
+++ b/cft/pkg/module_test.go
@@ -128,3 +128,7 @@ func TestCsvToSequence(t *testing.T) {
 		t.Errorf("Unexpected sequence")
 	}
 }
+
+func init() {
+	pkg.NoAnalytics = true
+}
diff --git a/test/templates/analytics.yaml b/test/templates/analytics.yaml
new file mode 100644
index 00000000..9d94994b
--- /dev/null
+++ b/test/templates/analytics.yaml
@@ -0,0 +1,5 @@
+Metadata:
+  Comment: abc
+Resources:
+  Bucket:
+    Type: AWS::S3::Bucket