using System; using System.CodeDom; using System.CodeDom.Compiler; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Reflection; using Amazon.AwsToolkit.Telemetry.Events.Generator.Models; using Amazon.AwsToolkit.Telemetry.Events.Generator.Utils; namespace Amazon.AwsToolkit.Telemetry.Events.Generator { /// /// Generates code that allows programs like the Toolkit to instantiate and publish Telemetry Events /// public class DefinitionsBuilder { private const string MetadataEntryFullName = "MetadataEntry"; private const string MetricDatumFullName = "MetricDatum"; private const string AddMetadataMethodName = "AddMetadata"; private const string InvokeTransformMethodName = "InvokeTransform"; // contains metadata fields that should be skipped when generating code. // These fields are covered by the class BaseTelemetryEvent private static readonly string[] ImplicitFields = { "reason", "errorCode", "causedBy", "httpStatusCode", "requestId", "requestServiceType", "duration", "locale" }; private readonly CodeMethodReferenceExpression _invariantCulture = new CodeMethodReferenceExpression(new CodeTypeReferenceExpression(typeof(CultureInfo)), nameof(CultureInfo.InvariantCulture)); private readonly CodeMethodReferenceExpression _debugAssert = new CodeMethodReferenceExpression(new CodeTypeReferenceExpression(typeof(System.Diagnostics.Debug)), "Assert"); private string _namespace; // Used for lookup during generation private readonly List _types = new List(); // Types to produce generated code for private readonly List _typesToGenerate = new List(); private readonly List _metrics = new List(); /// /// Supply Metrics Type definitions to the builder /// /// /// /// When true, the types will only be used to assist in generating code for metrics. /// The code that is generated is expected to reside somewhere that can reference these types. /// This would be used by repo-specific telemetry definitions. /// When false, the types will have code produced to define them. /// This would be used by the toolkit common telemetry definitions, and baked into a package. /// public DefinitionsBuilder AddMetricsTypes(IList types, bool referenceOnly = false) { _types.AddRange(types); if (!referenceOnly) { _typesToGenerate.AddRange(types); } return this; } public DefinitionsBuilder AddMetrics(IList metrics) { _metrics.AddRange(metrics); return this; } public DefinitionsBuilder WithNamespace(string generatedNamespace) { _namespace = generatedNamespace; return this; } public string Build() { if (string.IsNullOrWhiteSpace(_namespace)) { throw new MissingFieldException("Namespace not provided"); } var blankNamespace = new CodeNamespace(); // Used for top level Using statements var generatedNamespace = new CodeNamespace(_namespace); // Where generated classes, types, etc are added to var generatedCode = new CodeCompileUnit(); generatedCode.Namespaces.Add(blankNamespace); generatedCode.Namespaces.Add(generatedNamespace); // Add a top level comment blankNamespace.Comments.Add(new CodeCommentStatement("--------------------------------------------------------------------------------", true)); blankNamespace.Comments.Add(new CodeCommentStatement("This file is generated from https://github.com/aws/aws-toolkit-common/tree/main/telemetry", true)); blankNamespace.Comments.Add(new CodeCommentStatement("--------------------------------------------------------------------------------", true)); // Set up top level using statements blankNamespace.Imports.Add(new CodeNamespaceImport("System")); blankNamespace.Imports.Add(new CodeNamespaceImport("System.Collections.Generic")); // All generated code is expected to be placed in, or somewhere with a dependency on, // AwsToolkit.Telemetry.Events.Generated if (_namespace != Options.DefaultEventsNamespace) { blankNamespace.Imports.Add(new CodeNamespaceImport(Options.DefaultEventsNamespace)); } blankNamespace.Imports.Add(new CodeNamespaceImport("Amazon.AwsToolkit.Telemetry.Events.Core")); // "public sealed partial class ToolkitTelemetryEvent" (contains generated code the toolkit uses to record metrics) var telemetryEventsClass = new CodeTypeDeclaration() { Name = "ToolkitTelemetryEvent", IsClass = true, IsPartial = true, TypeAttributes = TypeAttributes.Public }; telemetryEventsClass.Comments.Add(new CodeCommentStatement("Contains methods to record telemetry events", true)); generatedNamespace.Types.Add(telemetryEventsClass); ProcessMetricTypes(generatedNamespace); ProcessMetrics(telemetryEventsClass, generatedNamespace); // Output generated code to a string CodeDomProvider provider = CodeDomProvider.CreateProvider("CSharp"); CodeGeneratorOptions options = new CodeGeneratorOptions { BracingStyle = "C", BlankLinesBetweenMembers = true }; using (var writer = new StringWriter()) { provider.GenerateCodeFromCompileUnit(generatedCode, writer, options); return writer.ToString() // XXX: CodeDom does not support static class generation. Post processing to accomplish this. .Replace($"public partial class {telemetryEventsClass.Name}", $"public static partial class {telemetryEventsClass.Name}"); } } /// /// Generate code to support defined metric types /// /// private void ProcessMetricTypes(CodeNamespace generatedNamespace) { _typesToGenerate.ForEach(metricType => ProcessMetricType(metricType, generatedNamespace)); } /// /// Generate code to support a metric type /// internal void ProcessMetricType(MetricType type, CodeNamespace generatedNamespace) { // Handle non-POCO types if (!type.IsAliasedType()) { // Generate strongly typed code for types that contain "allowed values" generatedNamespace.Types.Add(GenerateEnumStruct(type)); } } /// /// Given a type that contains a set of allowed values, generates a struct containing static fields. /// private CodeTypeDeclaration GenerateEnumStruct(MetricType type) { var typeDeclaration = new CodeTypeDeclaration(type.GetGeneratedTypeName()) { IsStruct = true, TypeAttributes = TypeAttributes.Public | TypeAttributes.Sealed }; typeDeclaration.Comments.Add(new CodeCommentStatement("Metric field type", true)); if (!string.IsNullOrWhiteSpace(type.description)) { typeDeclaration.Comments.Add(new CodeCommentStatement(type.description, true)); } var valueField = new CodeMemberField(typeof(string), "_value"); valueField.Attributes = MemberAttributes.Private; typeDeclaration.Members.Add(valueField); // Generate the constructor (stores provided value in _value) var typeConstructor = new CodeConstructor(); typeConstructor.Attributes = MemberAttributes.Public; typeConstructor.Parameters.Add(new CodeParameterDeclarationExpression("System.string", "value")); // this._value = value; var valueFieldRef = new CodeFieldReferenceExpression(new CodeThisReferenceExpression(), valueField.Name); typeConstructor.Statements.Add(new CodeAssignStatement(valueFieldRef, new CodeArgumentReferenceExpression("value"))); typeDeclaration.Members.Add(typeConstructor); // Generate static fields for each allowed value type.allowedValues? .ToList() .ForEach(allowedValue => { // eg: public static readonly Runtime Dotnetcore21 = new Runtime("dotnetcore2.1") CodeMemberField field = new CodeMemberField($"readonly {type.GetGeneratedTypeName()}", allowedValue.ToPascalCase().Replace(".", "").Replace("-", "")) { InitExpression = new CodeObjectCreateExpression(type.GetGeneratedTypeName(), new CodeExpression[] {new CodePrimitiveExpression(allowedValue)}), Attributes = MemberAttributes.Static | MemberAttributes.Public }; field.Comments.Add(new CodeCommentStatement(allowedValue, true)); typeDeclaration.Members.Add(field); }); // Generate a ToString method, which returns this._value // ToString is used by the AddMetadata method var toString = new CodeMemberMethod() { Name = "ToString", Attributes = MemberAttributes.Public | MemberAttributes.Override, ReturnType = new CodeTypeReference(typeof(string)) }; toString.Statements.Add( new CodeMethodReturnStatement(new CodeFieldReferenceExpression(new CodeThisReferenceExpression(), valueField.Name))); typeDeclaration.Members.Add(toString); return typeDeclaration; } /// /// Generate code to support defined metrics /// /// private void ProcessMetrics(CodeTypeDeclaration telemetryEventsClass, CodeNamespace generatedNamespace) { _metrics.ForEach(metric => ProcessMetric(metric, telemetryEventsClass, generatedNamespace)); } /// /// Generate code to support a metric /// private void ProcessMetric(Metric metric, CodeTypeDeclaration telemetryEventsClass, CodeNamespace generatedNamespace) { generatedNamespace.Types.Add(CreateMetricDataClass(metric)); telemetryEventsClass.Members.Add(CreateRecordMetricMethodByDataClass(metric)); } /// /// Generates the data class used by the toolkit to represent this metric /// private CodeTypeDeclaration CreateMetricDataClass(Metric metric) { var cls = new CodeTypeDeclaration { IsClass = true, Name = SanitizeName(metric.name), TypeAttributes = TypeAttributes.Public | TypeAttributes.Sealed }; cls.BaseTypes.Add("BaseTelemetryEvent"); if (!string.IsNullOrWhiteSpace(metric.description)) { cls.Comments.Add(new CodeCommentStatement(metric.description, true)); } // Generate the constructor var typeConstructor = new CodeConstructor {Attributes = MemberAttributes.Public}; // Initialize the passive field based on this metric declaration // Generate: this.Passive = true/false; var valueFieldRef = new CodeFieldReferenceExpression(new CodeThisReferenceExpression(), "Passive"); typeConstructor.Statements.Add(new CodeAssignStatement(valueFieldRef, new CodePrimitiveExpression(metric.passive))); cls.Members.Add(typeConstructor); // Generate the class members metric.metadata? .Where(metadata => !ImplicitFields.Contains(metadata.type)) .ToList().ForEach(metadata => { var metricType = GetMetricType(metadata.type); var fieldName = metadata.type.ToPascalCase(); var generatedTypeName = metricType.GetGeneratedTypeName(); if (IsNullable(metadata)) { generatedTypeName += "?"; } var field = new CodeMemberField(generatedTypeName, fieldName) { Attributes = MemberAttributes.Public }; var description = $"{(metadata.ResolvedRequired ? "" : "Optional - ")}{metricType.description ?? string.Empty}"; if (!string.IsNullOrEmpty(description)) { field.Comments.Add(new CodeCommentStatement(description, true)); } cls.Members.Add(field); }); return cls; } // Eg: 'count' -> "Unit.Count" private CodeExpression GetMetricUnitExpression(Metric metric) { var unit = metric.unit ?? "None"; // Fall back to "Unit.None" if there is no metric unit provided return new CodeFieldReferenceExpression(new CodeTypeReferenceExpression("Unit"), unit.ToPascalCase()); } /// /// Generates the "Record Metric" method used by the toolkit to send this metric to the backend /// private CodeMemberMethod CreateRecordMetricMethodByDataClass(Metric metric) { CodeMemberMethod recordMethod = new CodeMemberMethod { Attributes = MemberAttributes.Public | MemberAttributes.Static, Name = $"Record{SanitizeName(metric.name)}", ReturnType = new CodeTypeReference() }; if (!string.IsNullOrWhiteSpace(metric.description)) { recordMethod.Comments.Add(new CodeCommentStatement("Records Telemetry Event:", true)); recordMethod.Comments.Add(new CodeCommentStatement(metric.description, true)); } // RecordXxx Parameters var telemetryLogger = new CodeParameterDeclarationExpression("this ITelemetryLogger", "telemetryLogger"); recordMethod.Parameters.Add(telemetryLogger); recordMethod.Parameters.Add(new CodeParameterDeclarationExpression(SanitizeName(metric.name), "payload")); recordMethod.Parameters.Add(new CodeParameterDeclarationExpression("Func", "transformDatum = null")); // Generate method body var tryStatements = new List(); var catchClauses = new List(); // Create a metrics object from the given payload // Generate the method body var metrics = new CodeVariableReferenceExpression("metrics"); var metricsDataField = new CodeFieldReferenceExpression(metrics, "Data"); var payload = new CodeArgumentReferenceExpression("payload"); var transformDatum = new CodeArgumentReferenceExpression("transformDatum"); var datum = new CodeVariableReferenceExpression("datum"); var datumAddData = new CodeMethodReferenceExpression(datum, AddMetadataMethodName); var datetimeNow = new CodeMethodReferenceExpression(new CodeTypeReferenceExpression(typeof(DateTime)), nameof(DateTime.Now)); // Instantiate metrics tryStatements.Add(new CodeVariableDeclarationStatement("var", metrics.VariableName, new CodeObjectCreateExpression("Metrics"))); // Set metrics.CreatedOn to (payload.CreatedOn ?? DateTime.Now) var payloadCreatedOn = new CodeFieldReferenceExpression(payload, "CreatedOn"); var metricsCreatedOn = new CodeFieldReferenceExpression(metrics, "CreatedOn"); var createdOnCond = new CodeConditionStatement(); createdOnCond.Condition = new CodeFieldReferenceExpression(payloadCreatedOn, "HasValue"); createdOnCond.TrueStatements.Add(new CodeAssignStatement(metricsCreatedOn, new CodeFieldReferenceExpression(payloadCreatedOn, "Value"))); createdOnCond.FalseStatements.Add(new CodeAssignStatement(metricsCreatedOn, datetimeNow)); tryStatements.Add(createdOnCond); // Instantiate a Data list tryStatements.Add(new CodeAssignStatement(metricsDataField, new CodeObjectCreateExpression($"List<{MetricDatumFullName}>"))); // Instantiate MetricDatum tryStatements.Add(new CodeSnippetStatement()); tryStatements.Add(new CodeVariableDeclarationStatement("var", datum.VariableName, new CodeObjectCreateExpression(MetricDatumFullName))); tryStatements.Add(new CodeAssignStatement(new CodeFieldReferenceExpression(datum, "MetricName"), new CodePrimitiveExpression(metric.name))); tryStatements.Add(new CodeAssignStatement(new CodeFieldReferenceExpression(datum, "Unit"), GetMetricUnitExpression(metric))); tryStatements.Add(new CodeAssignStatement(new CodeFieldReferenceExpression(datum, "Passive"), new CodeFieldReferenceExpression(payload, "Passive"))); // Set Datum.Value to (payload.Value ?? 1) var payloadValue = new CodeFieldReferenceExpression(payload, "Value"); var datumValue = new CodeFieldReferenceExpression(datum, "Value"); var valueCond = new CodeConditionStatement(); valueCond.Condition = new CodeFieldReferenceExpression(payloadValue, "HasValue"); valueCond.TrueStatements.Add(new CodeAssignStatement(datumValue, new CodeFieldReferenceExpression(payloadValue, "Value"))); valueCond.FalseStatements.Add(new CodeAssignStatement(datumValue, new CodePrimitiveExpression(1))); tryStatements.Add(valueCond); // Generate: datum.AddMetadata("awsAccount", payload.AwsAccount); var payloadAwsAccount = new CodeFieldReferenceExpression(payload, "AwsAccount"); tryStatements.Add(new CodeExpressionStatement(new CodeMethodInvokeExpression(datumAddData, new CodePrimitiveExpression("awsAccount"), payloadAwsAccount))); // Generate: datum.AddMetadata("awsRegion", payload.AwsRegion); var payloadAwsRegion = new CodeFieldReferenceExpression(payload, "AwsRegion"); tryStatements.Add(new CodeExpressionStatement(new CodeMethodInvokeExpression(datumAddData, new CodePrimitiveExpression("awsRegion"), payloadAwsRegion))); // Generate: datum.AddMetadata("reason", payload.Reason); var payloadReason = new CodeFieldReferenceExpression(payload, "Reason"); tryStatements.Add(new CodeExpressionStatement(new CodeMethodInvokeExpression(datumAddData, new CodePrimitiveExpression("reason"), payloadReason))); // Generate: datum.AddMetadata("errorCode", payload.ErrorCode); var payloadErrorCode = new CodeFieldReferenceExpression(payload, "ErrorCode"); tryStatements.Add(new CodeExpressionStatement(new CodeMethodInvokeExpression(datumAddData, new CodePrimitiveExpression("errorCode"), payloadErrorCode))); // Generate: datum.AddMetadata("causedBy", payload.CausedBy); var payloadCausedBy = new CodeFieldReferenceExpression(payload, "CausedBy"); tryStatements.Add(new CodeExpressionStatement(new CodeMethodInvokeExpression(datumAddData, new CodePrimitiveExpression("causedBy"), payloadCausedBy))); // Generate: datum.AddMetadata("httpStatusCode", payload.HttpStatusCode); var payloadHttpStatusCode = new CodeFieldReferenceExpression(payload, "HttpStatusCode"); tryStatements.Add(new CodeExpressionStatement(new CodeMethodInvokeExpression(datumAddData, new CodePrimitiveExpression("httpStatusCode"), payloadHttpStatusCode))); // Generate: datum.AddMetadata("requestId", payload.RequestId); var payloadRequestId = new CodeFieldReferenceExpression(payload, "RequestId"); tryStatements.Add(new CodeExpressionStatement(new CodeMethodInvokeExpression(datumAddData, new CodePrimitiveExpression("requestId"), payloadRequestId))); // Generate: datum.AddMetadata("requestServiceType", payload.RequestServiceType); var payloadRequestServiceType = new CodeFieldReferenceExpression(payload, "RequestServiceType"); tryStatements.Add(new CodeExpressionStatement(new CodeMethodInvokeExpression(datumAddData, new CodePrimitiveExpression("requestServiceType"), payloadRequestServiceType))); // Generate: // if (payload.Duration.HasValue) // { // datum.AddMetadata("duration", payload.Duration.Value); // } var payloadDuration= new CodeFieldReferenceExpression(payload, "Duration"); var hasValueDuration = new CodeFieldReferenceExpression(payloadDuration, "HasValue"); var durationMetadata = new CodeMethodInvokeExpression(datumAddData, new CodePrimitiveExpression("duration"), new CodeFieldReferenceExpression(payloadDuration, "Value")); tryStatements.Add(new CodeConditionStatement(hasValueDuration, new CodeExpressionStatement(durationMetadata))); // Generate: datum.AddMetadata("locale", payload.Locale); var payloadLocale = new CodeFieldReferenceExpression(payload, "Locale"); tryStatements.Add(new CodeExpressionStatement(new CodeMethodInvokeExpression(datumAddData, new CodePrimitiveExpression("locale"), payloadLocale))); // Set MetricDatum Metadata values metric.metadata? .Where(metadata => !ImplicitFields.Contains(metadata.type)) .ToList().ForEach(metadata => { tryStatements.Add(new CodeSnippetStatement()); var payloadField = new CodeFieldReferenceExpression(payload, SanitizeName(metadata.type)); if (IsNullable(metadata)) { // Generate: // if (payload.foo.HasValue) // { // datum.AddMetadata("foo", payload.foo.Value); // } var hasValue = new CodeFieldReferenceExpression(payloadField, "HasValue"); var addMetadata = new CodeMethodInvokeExpression(datumAddData, new CodePrimitiveExpression(metadata.type), new CodeFieldReferenceExpression(payloadField, "Value")); tryStatements.Add( new CodeConditionStatement(hasValue, new CodeExpressionStatement(addMetadata))); } else { // Generate: datum.AddMetadata("foo", payload.foo); tryStatements.Add(new CodeExpressionStatement (new CodeMethodInvokeExpression(datumAddData, new CodePrimitiveExpression(metadata.type), payloadField))); } }); // Generate: "InvokeTransform function on datum" // datum = datum.InvokeTransform(transformDatum) var datumInvoke = new CodeMethodReferenceExpression(datum, InvokeTransformMethodName); var invokeTransform = new CodeMethodInvokeExpression(datumInvoke, transformDatum); var assignTransform = new CodeAssignStatement(datum, invokeTransform); tryStatements.Add(new CodeSnippetStatement()); tryStatements.Add(assignTransform); // Generate: metrics.Data.Add(datum); tryStatements.Add(new CodeSnippetStatement()); tryStatements.Add(new CodeExpressionStatement (new CodeMethodInvokeExpression(metricsDataField, "Add", datum))); // Generate: telemetryLogger.Record(metrics); tryStatements.Add(new CodeExpressionStatement (new CodeMethodInvokeExpression(new CodeArgumentReferenceExpression("telemetryLogger"), "Record", metrics))); var catchClause = new CodeCatchClause("e", new CodeTypeReference(typeof(Exception))); catchClause.Statements.Add(new CodeExpressionStatement( new CodeMethodInvokeExpression( new CodeFieldReferenceExpression(new CodeArgumentReferenceExpression("telemetryLogger"), "Logger"), "Error", new CodePrimitiveExpression("Error recording telemetry event"), new CodeArgumentReferenceExpression("e")) )); // System.Diagnostics.Debug.Assert(false, "Error Recording Telemetry"); catchClause.Statements.Add(new CodeExpressionStatement( new CodeMethodInvokeExpression( _debugAssert, new CodePrimitiveExpression(false), new CodePrimitiveExpression("Error Recording Telemetry")) )); catchClauses.Add(catchClause); recordMethod.Statements.Add( new CodeTryCatchFinallyStatement(tryStatements.ToArray(), catchClauses.ToArray()) ); return recordMethod; } private bool IsNullable(Metadata metadata) { var metricType = GetMetricType(metadata.type); if (!metricType.IsAliasedType()) { return !metadata.ResolvedRequired; } var type = metricType.GetAliasedType(); // System.string cannot be made nullable return type != typeof(string) && !metadata.ResolvedRequired; } private MetricType GetMetricType(string name) { return _types.Single(t => t.name == name); } private string SanitizeName(string name) { return string.Join( "", name .Split(new char[] {'.', ',', '_', '-'}, StringSplitOptions.RemoveEmptyEntries) .Select(x => x.ToPascalCase()) ); } } }