Repository: klothoplatform/klotho (go)

Issue:
better handling of analytics properties
We have a mechanism to attach custom information to our log messages, which then gets sanitized and sent to our analytics server. Unfortunately, the only way we can send it today is by including it in the analytic event's name text. This is (I think) supposed to be human readable and succinct, but when we use the sanitization mechanism, it comes through as json.

This has a couple disadvantages:

1. It's hard to see the data in our dashboard; the views are optimized assuming that the event name is human-readable
2. I think (citation needed) it's hard to _use_ in our dashboard; the "event name" field doesn't get auto-parsed

I suspect issue 1 was the motivation behind [`logging.EntryMessageField`][1], which just replaces the analytics message text with the log message. In addition to other problems, this also means that it clobbers any other information that might have been on the sanitized field. I don't think it's a problem in actual usage today, but it's a hidden landmine.

We should consider an alternate way to send these properties, in a way that makes them actually be properties. For example, we could turn each `$key` into a real analytics property, `klotho.$key`. This would require a bit of hackery because of how we currently create and send these messages, but nothing difficult.

[1]: https://github.com/klothoplatform/klotho/blob/acafab93adcee780099b8316dc37a8f06f5da56c/pkg/analytics/fields_listener.go#L49-L53

Pull request:
send properties separately from event name in logs
When we hook into the log entries, don't send the sanitized fields as json in the event name. Instead, just send the log level for the event name (e.g. "WARN"), and send each sanitized field as a property with a dot-delimited key. For example, a file field would send:

    log.file.extension: .json
    log.file.path: sha256:<snip>

This resolves #239.

### Standard checks

- **Unit tests**: not unit tested; see our analytics dashboard for how this looks
- **Docs**: not needed
- **Backwards compatibility**: Analytics property names and formats have changed, so if we're relying on any for reports, then those would break. I don't think we're doing that.

Patch:
diff --git a/pkg/analytics/client.go b/pkg/analytics/client.go
index a3ce9bb4..8bdcff33 100644
--- a/pkg/analytics/client.go
+++ b/pkg/analytics/client.go
@@ -26,7 +26,7 @@ type (
 	LogLevel string
 )
 
-var (
+const (
 	Panic LogLevel = "panic"
 	Error LogLevel = "error"
 	Warn  LogLevel = "warn"
@@ -34,8 +34,8 @@ var (
 	Debug LogLevel = "debug"
 )
 
-var datadogLogLevel = "_logLevel"
-var datadogStatus = "status"
+const datadogLogLevel = "_logLevel"
+const datadogStatus = "status"
 
 func NewClient(properties map[string]interface{}) *Client {
 	local := GetOrCreateAnalyticsFile()
@@ -97,6 +97,10 @@ func (t *Client) AppendProperties(properties map[string]interface{}) {
 	}
 }
 
+func (t *Client) DeleteProperty(key string) {
+	delete(t.Properties, key)
+}
+
 func (t *Client) UploadSource(source *core.InputFiles) {
 	data, err := CompressFiles(source)
 	if err != nil {
diff --git a/pkg/analytics/fields_listener.go b/pkg/analytics/fields_listener.go
index 65eeca0f..fa96eaf2 100644
--- a/pkg/analytics/fields_listener.go
+++ b/pkg/analytics/fields_listener.go
@@ -4,6 +4,7 @@ package analytics
 // as the main entry.
 
 import (
+	"fmt"
 	"github.com/klothoplatform/klotho/pkg/logging"
 	"go.uber.org/zap/zapcore"
 )
@@ -44,27 +45,37 @@ func (fl *fieldListener) Write(entry zapcore.Entry, fields []zapcore.Field) erro
 	allFields = append(allFields, fl.fields...)
 	allFields = append(allFields, fields...)
 
-	safeLogsStr := logging.SanitizeFields(allFields, fl.client.Hash)
+	message := entry.Level.CapitalString()
+
+	for k, v := range logging.SanitizeFields(allFields, fl.client.Hash) {
+		property := fmt.Sprintf("log.%s", k)
+		fl.client.Properties[property] = v
+		defer func() { fl.client.DeleteProperty(property) }()
+	}
 
 	for _, f := range allFields {
 		if f.Key == logging.EntryMessageField {
-			safeLogsStr = entry.Message
+			message += (" " + entry.Message)
+		}
+		if err, isError := f.Interface.(error); isError {
+			fl.client.Properties["error"] = fmt.Sprintf("%+v", err)
+			defer fl.client.DeleteProperty("error")
 		}
 	}
 
 	switch entry.Level {
 	case zapcore.DebugLevel:
-		fl.client.Debug(safeLogsStr)
+		fl.client.Debug(message)
 	case zapcore.InfoLevel:
-		fl.client.Info(safeLogsStr)
+		fl.client.Info(message)
 	case zapcore.WarnLevel:
-		fl.client.Warn(safeLogsStr)
+		fl.client.Warn(message)
 	case zapcore.ErrorLevel:
-		fl.client.Error(safeLogsStr)
+		fl.client.Error(message)
 	case zapcore.DPanicLevel, zapcore.PanicLevel, zapcore.FatalLevel:
-		fl.client.Panic(safeLogsStr)
+		fl.client.Panic(message)
 	default:
-		fl.client.Warn(safeLogsStr) // shouldn't happen, but just to be safe
+		fl.client.Warn(message) // shouldn't happen, but just to be safe
 	}
 	return nil
 }
diff --git a/pkg/cli/klothomain.go b/pkg/cli/klothomain.go
index 21c37aa6..3dcf87c5 100644
--- a/pkg/cli/klothomain.go
+++ b/pkg/cli/klothomain.go
@@ -209,7 +209,7 @@ func (km KlothoMain) run(cmd *cobra.Command, args []string) (err error) {
 	}
 
 	// Set up analytics, and hook them up to the logs
-	analyticsClient := analytics.NewClient(map[string]interface{}{
+	analyticsClient := analytics.NewClient(map[string]any{
 		"version": km.Version,
 		"strict":  cfg.strict,
 		"edition": km.DefaultUpdateStream,
@@ -363,7 +363,7 @@ func (km KlothoMain) run(cmd *cobra.Command, args []string) (err error) {
 	}
 
 	// Update analytics with app configs
-	analyticsClient.AppendProperties(map[string]interface{}{
+	analyticsClient.AppendProperties(map[string]any{
 		"provider": appCfg.Provider,
 		"app":      appCfg.AppName,
 	})
@@ -423,9 +423,11 @@ func (km KlothoMain) run(cmd *cobra.Command, args []string) (err error) {
 	}
 
 	CloseTreeSitter(result)
-	analyticsClient.AppendProperties(map[string]interface{}{"resource_types": GetResourceTypeCount(result, &appCfg)})
-	analyticsClient.AppendProperties(map[string]interface{}{"languages": GetLanguagesUsed(result)})
-	analyticsClient.AppendProperties(map[string]interface{}{"resources": resourceCounts})
+	analyticsClient.AppendProperties(map[string]any{
+		"resource_types": GetResourceTypeCount(result, &appCfg),
+		"languages":      GetLanguagesUsed(result),
+		"resources":      resourceCounts,
+	})
 	analyticsClient.Info(klothoName + " compile complete")
 
 	return nil
diff --git a/pkg/infra/pulumi_aws/deploylib.ts b/pkg/infra/pulumi_aws/deploylib.ts
index 541be35b..aefccea9 100755
--- a/pkg/infra/pulumi_aws/deploylib.ts
+++ b/pkg/infra/pulumi_aws/deploylib.ts
@@ -62,7 +62,7 @@ export interface TopologyEdgeData {
     target: string
 }
 
-export const kloConfig = new pulumi.Config('klo')
+export const kloConfig: pulumi.Config = new pulumi.Config('klo')
 
 export class CloudCCLib {
     secrets = new Map<string, aws.secretsmanager.Secret>()
@@ -1003,174 +1003,6 @@ export class CloudCCLib {
             )
     }
 
-    public setupRDS(orm: string, args: Partial<aws.rds.InstanceArgs>) {
-        if (!this.subnetGroup) {
-            const subnetGroupName = sanitized(AwsSanitizer.RDS.dbSubnetGroup.nameValidation())`${h(
-                this.name
-            )}`
-            this.subnetGroup = new aws.rds.SubnetGroup(subnetGroupName, {
-                subnetIds: this.privateSubnetIds,
-                tags: {
-                    Name: 'Klotho DB subnet group',
-                },
-            })
-        }
-
-        const dbName = sanitized(
-            AwsSanitizer.RDS.engine.pg.database.nameValidation()
-        )`${orm.toLowerCase()}`
-        const config = new pulumi.Config()
-        const username = config.require(`${dbName}_username`)
-        const password = config.requireSecret(`${dbName}_password`)
-
-        // create the db resources
-        validate(dbName, AwsSanitizer.RDS.instance.nameValidation())
-        const rds = new aws.rds.Instance(
-            dbName,
-            {
-                instanceClass: 'db.t4g.micro',
-                ...args,
-                engine: 'postgres',
-                dbName: dbName,
-                username: username,
-                password: password,
-                iamDatabaseAuthenticationEnabled: true,
-                dbSubnetGroupName: this.subnetGroup.name,
-                vpcSecurityGroupIds: this.sgs,
-            },
-            { protect: this.protect }
-        )
-
-        // setup secrets for the proxy
-        const secretName = `${dbName}_secret`
-        const ssmSecretName = `${this.name}-${secretName}`
-        validate(ssmSecretName, AwsSanitizer.SecretsManager.secret.nameValidation())
-        let rdsSecret = new aws.secretsmanager.Secret(`${secretName}`, {
-            name: ssmSecretName,
-            recoveryWindowInDays: 0,
-        })
-
-        const rdsSecretValues = {
-            username: username,
-            password: password,
-            engine: 'postgres',
-            host: rds.address,
-            port: rds.port,
-            dbname: dbName,
-            dbInstanceIdentifier: rds.id,
-            iamDatabaseAuthenticationEnabled: false,
-        }
-
-        const secret = new aws.secretsmanager.SecretVersion(`${secretName}`, {
-            secretId: rdsSecret.id,
-            secretString: pulumi.output(rdsSecretValues).apply((v) => JSON.stringify(v)),
-        })
-
-        this.topology.topologyIconData.forEach((resource) => {
-            if (resource.kind == Resource.secret) {
-                this.topology.topologyEdgeData.forEach((edge) => {
-                    if (edge.target == resource.id) {
-                        this.addPolicyStatementForName(
-                            this.resourceIdToResource.get(edge.source).title,
-                            {
-                                Effect: 'Allow',
-                                Action: ['secretsmanager:GetSecretValue'],
-                                Resource: [secret.arn],
-                            }
-                        )
-                    }
-                })
-            }
-        })
-
-        // prettier-ignore
-        const ormRoleName = sanitized(AwsSanitizer.IAM.role.nameValidation())`${h(dbName)}-ormsecretrole`
-        //setup role for proxy
-        const role = new aws.iam.Role(ormRoleName, {
-            assumeRolePolicy: {
-                Version: '2012-10-17',
-                Statement: [
-                    {
-                        Effect: 'Allow',
-                        Principal: {
-                            Service: 'rds.amazonaws.com',
-                        },
-                        Action: 'sts:AssumeRole',
-                    },
-                ],
-            },
-        })
-
-        // prettier-ignore
-        const ormPolicyName = sanitized(AwsSanitizer.IAM.policy.nameValidation())`${h(dbName)}-ormsecretpolicy`
-        const policy = new aws.iam.Policy(ormPolicyName, {
-            description: 'klotho orm secret policy',
-            policy: {
-                Version: '2012-10-17',
-                Statement: [
-                    {
-                        Effect: 'Allow',
-                        Action: 'secretsmanager:GetSecretValue',
-                        Resource: [secret.arn],
-                    },
-                ],
-            },
-        })
-
-        const attach = new aws.iam.RolePolicyAttachment(`${dbName}-ormattach`, {
-            role: role.name,
-            policyArn: policy.arn,
-        })
-
-        // setup the rds proxy
-        const proxyName = sanitized(AwsSanitizer.RDS.dbProxy.nameValidation())`${h(dbName)}`
-        const proxy = new aws.rds.Proxy(proxyName, {
-            debugLogging: false,
-            engineFamily: 'POSTGRESQL',
-            idleClientTimeout: 1800,
-            requireTls: false,
-            roleArn: role.arn,
-            vpcSecurityGroupIds: this.sgs,
-            vpcSubnetIds: this.privateSubnetIds,
-            auths: [
-                {
-                    authScheme: 'SECRETS',
-                    description: 'use the secrets generated by klotho',
-                    iamAuth: 'DISABLED',
-                    secretArn: secret.arn,
-                },
-            ],
-        })
-
-        const proxyDefaultTargetGroup = new aws.rds.ProxyDefaultTargetGroup(`${dbName}`, {
-            dbProxyName: proxy.name,
-            connectionPoolConfig: {
-                connectionBorrowTimeout: 120,
-                maxConnectionsPercent: 100,
-                maxIdleConnectionsPercent: 50,
-            },
-        })
-        const proxyTarget = new aws.rds.ProxyTarget(`${dbName}`, {
-            dbInstanceIdentifier: rds.id,
-            dbProxyName: proxy.name,
-            targetGroupName: proxyDefaultTargetGroup.name,
-        })
-
-        const clients = this.addConnectionString(
-            orm,
-            pulumi.interpolate`postgresql://${username}:${password}@${proxy.endpoint}:5432/${dbName}`
-        )
-
-        const resource = pulumi.interpolate`arn:aws:rds-db:${this.region}:${this.account.accountId}:dbuser:${rds.resourceId}/${username}`
-        for (const client of clients) {
-            this.addPolicyStatementForName(this.resourceIdToResource.get(client).title, {
-                Effect: 'Allow',
-                Action: ['rds-db:connect'],
-                Resource: resource,
-            })
-        }
-    }
-
     public addConnectionString(orm: string, connectionString: pulumi.Output<string>) {
         const clients: string[] = []
         this.topology.topologyIconData.forEach((resource) => {
diff --git a/pkg/infra/pulumi_aws/iac/rds.ts b/pkg/infra/pulumi_aws/iac/rds.ts
new file mode 100644
index 00000000..c36d2eb5
--- /dev/null
+++ b/pkg/infra/pulumi_aws/iac/rds.ts
@@ -0,0 +1,220 @@
+import * as aws from '@pulumi/aws'
+import * as pulumi from '@pulumi/pulumi'
+import { hash as h, sanitized, validate } from './sanitization/sanitizer'
+import AwsSanitizer from './sanitization/aws'
+import { Resource, CloudCCLib, kloConfig } from '../deploylib'
+
+export interface CreateInstanceAndProxyResult {
+    rds: aws.rds.Instance
+    proxy: aws.rds.Proxy
+}
+
+export interface RdsImport {
+    dbInstanceIdentifier: string
+    proxy: string
+}
+
+export class RDS {
+    constructor() {}
+
+    static setupRDS(lib: CloudCCLib, orm: string, args: Partial<aws.rds.InstanceArgs>) {
+        const rdsImports: { [key: string]: RdsImport } | undefined = kloConfig.getObject('rds')
+        const config = new pulumi.Config()
+        const dbName = sanitized(
+            AwsSanitizer.RDS.engine.pg.database.nameValidation()
+        )`${orm.toLowerCase()}`
+        const username = config.require(`${dbName}_username`)
+        const password = config.requireSecret(`${dbName}_password`)
+
+        let rds
+        let proxy
+
+        if (rdsImports != undefined && rdsImports[orm] != undefined) {
+            rds = aws.rds.Instance.get(dbName, rdsImports[orm].dbInstanceIdentifier)
+            const proxyName = sanitized(AwsSanitizer.RDS.dbProxy.nameValidation())`${h(dbName)}`
+            proxy = aws.rds.Proxy.get(proxyName, rdsImports[orm].proxy)
+        } else {
+            const result: CreateInstanceAndProxyResult = RDS.createInstanceAndProxy(
+                lib,
+                dbName,
+                username,
+                password,
+                args
+            )
+            rds = result.rds
+            proxy = result.proxy
+        }
+
+        const clients = lib.addConnectionString(
+            orm,
+            pulumi.interpolate`postgresql://${username}:${password}@${proxy.endpoint}:5432/${dbName}`
+        )
+
+        const resource = pulumi.interpolate`arn:aws:rds-db:${lib.region}:${lib.account.accountId}:dbuser:${rds.resourceId}/${username}`
+        for (const client of clients) {
+            lib.addPolicyStatementForName(lib.resourceIdToResource.get(client).title, {
+                Effect: 'Allow',
+                Action: ['rds-db:connect'],
+                Resource: resource,
+            })
+        }
+    }
+
+    static createInstanceAndProxy(
+        lib: CloudCCLib,
+        dbName: string,
+        username: string,
+        password: pulumi.Output<string>,
+        args: Partial<aws.rds.InstanceArgs>
+    ): CreateInstanceAndProxyResult {
+        if (!lib.subnetGroup) {
+            const subnetGroupName = sanitized(AwsSanitizer.RDS.dbSubnetGroup.nameValidation())`${h(
+                lib.name
+            )}`
+            lib.subnetGroup = new aws.rds.SubnetGroup(subnetGroupName, {
+                subnetIds: lib.privateSubnetIds,
+                tags: {
+                    Name: 'Klotho DB subnet group',
+                },
+            })
+        }
+
+        // create the db resources
+        validate(dbName, AwsSanitizer.RDS.instance.nameValidation())
+        const rds = new aws.rds.Instance(
+            dbName,
+            {
+                instanceClass: 'db.t4g.micro',
+                ...args,
+                engine: 'postgres',
+                dbName: dbName,
+                username: username,
+                password: password,
+                iamDatabaseAuthenticationEnabled: true,
+                dbSubnetGroupName: lib.subnetGroup.name,
+                vpcSecurityGroupIds: lib.sgs,
+            },
+            { protect: lib.protect }
+        )
+
+        // setup secrets for the proxy
+        const secretName = `${dbName}_secret`
+        const ssmSecretName = `${lib.name}-${secretName}`
+        validate(ssmSecretName, AwsSanitizer.SecretsManager.secret.nameValidation())
+        let rdsSecret = new aws.secretsmanager.Secret(`${secretName}`, {
+            name: ssmSecretName,
+            recoveryWindowInDays: 0,
+        })
+
+        const rdsSecretValues = {
+            username: username,
+            password: password,
+            engine: 'postgres',
+            host: rds.address,
+            port: rds.port,
+            dbname: dbName,
+            dbInstanceIdentifier: rds.id,
+            iamDatabaseAuthenticationEnabled: false,
+        }
+
+        const secret = new aws.secretsmanager.SecretVersion(`${secretName}`, {
+            secretId: rdsSecret.id,
+            secretString: pulumi.output(rdsSecretValues).apply((v) => JSON.stringify(v)),
+        })
+
+        lib.topology.topologyIconData.forEach((resource) => {
+            if (resource.kind == Resource.secret) {
+                lib.topology.topologyEdgeData.forEach((edge) => {
+                    if (edge.target == resource.id) {
+                        lib.addPolicyStatementForName(
+                            lib.resourceIdToResource.get(edge.source).title,
+                            {
+                                Effect: 'Allow',
+                                Action: ['secretsmanager:GetSecretValue'],
+                                Resource: [secret.arn],
+                            }
+                        )
+                    }
+                })
+            }
+        })
+
+        // prettier-ignore
+        const ormRoleName = sanitized(AwsSanitizer.IAM.role.nameValidation())`${h(dbName)}-ormsecretrole`
+        //setup role for proxy
+        const role = new aws.iam.Role(ormRoleName, {
+            assumeRolePolicy: {
+                Version: '2012-10-17',
+                Statement: [
+                    {
+                        Effect: 'Allow',
+                        Principal: {
+                            Service: 'rds.amazonaws.com',
+                        },
+                        Action: 'sts:AssumeRole',
+                    },
+                ],
+            },
+        })
+
+        // prettier-ignore
+        const ormPolicyName = sanitized(AwsSanitizer.IAM.policy.nameValidation())`${h(dbName)}-ormsecretpolicy`
+        const policy = new aws.iam.Policy(ormPolicyName, {
+            description: 'klotho orm secret policy',
+            policy: {
+                Version: '2012-10-17',
+                Statement: [
+                    {
+                        Effect: 'Allow',
+                        Action: 'secretsmanager:GetSecretValue',
+                        Resource: [secret.arn],
+                    },
+                ],
+            },
+        })
+
+        const attach = new aws.iam.RolePolicyAttachment(`${dbName}-ormattach`, {
+            role: role.name,
+            policyArn: policy.arn,
+        })
+
+        // setup the rds proxy
+        const proxyName = sanitized(AwsSanitizer.RDS.dbProxy.nameValidation())`${h(dbName)}`
+        const proxy = new aws.rds.Proxy(proxyName, {
+            debugLogging: false,
+            engineFamily: 'POSTGRESQL',
+            idleClientTimeout: 1800,
+            requireTls: false,
+            roleArn: role.arn,
+            vpcSecurityGroupIds: lib.sgs,
+            vpcSubnetIds: lib.privateSubnetIds,
+            auths: [
+                {
+                    authScheme: 'SECRETS',
+                    description: 'use the secrets generated by klotho',
+                    iamAuth: 'DISABLED',
+                    secretArn: secret.arn,
+                },
+            ],
+        })
+
+        const proxyDefaultTargetGroup = new aws.rds.ProxyDefaultTargetGroup(`${dbName}`, {
+            dbProxyName: proxy.name,
+            connectionPoolConfig: {
+                connectionBorrowTimeout: 120,
+                maxConnectionsPercent: 100,
+                maxIdleConnectionsPercent: 50,
+            },
+        })
+        const proxyTarget = new aws.rds.ProxyTarget(`${dbName}`, {
+            dbInstanceIdentifier: rds.id,
+            dbProxyName: proxy.name,
+            targetGroupName: proxyDefaultTargetGroup.name,
+        })
+
+        return {
+            rds,
+            proxy,
+        }
+    }
+}
diff --git a/pkg/infra/pulumi_aws/index.ts.tmpl b/pkg/infra/pulumi_aws/index.ts.tmpl
index 74fb3492..bf020111 100755
--- a/pkg/infra/pulumi_aws/index.ts.tmpl
+++ b/pkg/infra/pulumi_aws/index.ts.tmpl
@@ -32,6 +32,10 @@ import { Cloudfront } from './iac/cloudfront'
 import { SecretsManager } from './iac/secrets_manager'
 {{end}}
 
+{{- if .RdsInstances}}
+import { RDS } from './iac/rds'
+{{end}}
+
 export = async () => {
     const minimumNodeVersion = 16
     const nodeVersionMatch = process.version.match(/^v(\d+)/)
@@ -91,10 +95,12 @@ export = async () => {
     new SecretsManager(cloudLib, {{json .SecretManagerSecrets}});
     {{- end}}
 
+    {{range $rds := .RdsInstances -}}
+    RDS.setupRDS(cloudLib, "{{$rds.Name}}", {{json $rds.Params}} as Partial<aws.rds.InstanceArgs>);
+    {{- end}}
+
     {{range $orm := .ORMs -}}
-    {{if eq $orm.Type "rds_postgres" -}}
-    cloudLib.setupRDS("{{$orm.Name}}", {{json $orm.Params}} as Partial<aws.rds.InstanceArgs>);
-    {{- else if eq $orm.Type "cockroachdb_serverless" -}}
+    {{if eq $orm.Type "cockroachdb_serverless" -}}
     cloudLib.addConnectionString(
         "{{$orm.Name}}",
         new CockroachDB("{{$orm.Name}}-db", {app: appName, id: "{{$orm.Name}}", region, topology{{if $orm.Params}}, ...{{json $orm.Params}}{{end -}} }).connectionString,
diff --git a/pkg/infra/pulumi_aws/plugin_iac.go b/pkg/infra/pulumi_aws/plugin_iac.go
index e2340f8c..09ff735a 100644
--- a/pkg/infra/pulumi_aws/plugin_iac.go
+++ b/pkg/infra/pulumi_aws/plugin_iac.go
@@ -137,6 +137,10 @@ func (p Plugin) Transform(result *core.CompilationResult, deps *core.Dependencie
 		addFile("iac/secrets_manager.ts")
 	}
 
+	if len(data.RdsInstances) > 0 {
+		addFile("iac/rds.ts")
+	}
+
 	addFile("deploylib.ts")
 	addFile("package.json")
 	addFile("tsconfig.json")
diff --git a/pkg/lang/javascript/aws_runtime/clients.js b/pkg/lang/javascript/aws_runtime/clients.js
index 96f5b728..782e0145 100644
--- a/pkg/lang/javascript/aws_runtime/clients.js
+++ b/pkg/lang/javascript/aws_runtime/clients.js
@@ -1,8 +1,8 @@
-const {S3Client} = require('@aws-sdk/client-s3')
-const {LambdaClient}  = require('@aws-sdk/client-lambda')
-const {SecretsManagerClient}  = require('@aws-sdk/client-secrets-manager')
-const { SNSClient }  = require('@aws-sdk/client-sns')
-const {DynamoDBClient}  = require('@aws-sdk/client-dynamodb')
+const { S3Client } = require('@aws-sdk/client-s3')
+const { LambdaClient } = require('@aws-sdk/client-lambda')
+const { SecretsManagerClient } = require('@aws-sdk/client-secrets-manager')
+const { SNSClient } = require('@aws-sdk/client-sns')
+const { DynamoDBClient } = require('@aws-sdk/client-dynamodb')
 const AWSXRay = require('aws-xray-sdk-core')
 
 const endpoint = process.env['AWS_ENDPOINT']
diff --git a/pkg/logging/fields.go b/pkg/logging/fields.go
index a2fd079c..ea0d4fb9 100644
--- a/pkg/logging/fields.go
+++ b/pkg/logging/fields.go
@@ -22,8 +22,8 @@ func (field fileField) Sanitize(hasher func(any) string) SanitizedField {
 		extension = filepath.Ext(field.f.Path())
 	}
 	return SanitizedField{
-		Key: "FileExtension",
-		Content: map[string]any{
+		Key: "file",
+		Content: map[string]string{
 			"extension": extension,
 			"path":      hasher(field.f.Path()),
 		},
@@ -51,8 +51,8 @@ func (field annotationField) MarshalLogObject(enc zapcore.ObjectEncoder) error {
 
 func (field annotationField) Sanitize(hasher func(any) string) SanitizedField {
 	return SanitizedField{
-		Key: "Capability",
-		Content: map[string]any{
+		Key: "annotation",
+		Content: map[string]string{
 			"name":       field.a.Capability.Name,
 			"id":         hasher(field.a.Capability.ID),
 			"directives": hasher(field.a.Capability.Directives),
@@ -96,11 +96,11 @@ func DescribeKlothoFields(fields []zapcore.Field, expected ...string) map[string
 
 	for _, field := range fields {
 		encoder.b.Reset()
-		marhaledField, ok := field.Interface.(zapcore.ObjectMarshaler)
+		marshaledField, ok := field.Interface.(zapcore.ObjectMarshaler)
 		if !ok {
 			continue
 		}
-		if err := encoder.AppendObject(marhaledField); err != nil {
+		if err := encoder.AppendObject(marshaledField); err != nil {
 			all[field.Key] = "!!(UNMARSHALING ERROR)"
 		} else {
 			all[field.Key] = encoder.b.String()
@@ -111,8 +111,8 @@ func DescribeKlothoFields(fields []zapcore.Field, expected ...string) map[string
 
 func (field astNodeField) Sanitize(hasher func(any) string) SanitizedField {
 	return SanitizedField{
-		Key: "AstNodeType",
-		Content: map[string]any{
+		Key: "ast_node",
+		Content: map[string]string{
 			"type":    field.n.Type(),
 			"content": hasher(field.n.Content()),
 		},
diff --git a/pkg/logging/safelogging.go b/pkg/logging/safelogging.go
index aa9b6e60..eead1c6f 100644
--- a/pkg/logging/safelogging.go
+++ b/pkg/logging/safelogging.go
@@ -1,7 +1,7 @@
 package logging
 
 import (
-	"encoding/json"
+	"fmt"
 	"go.uber.org/zap/zapcore"
 )
 
@@ -11,29 +11,23 @@ type (
 	}
 
 	SanitizedField struct {
-		Key     string `json:"key"`
-		Content any    `json:"content,omitempty"`
+		Key     string
+		Content map[string]string
 	}
 )
 
-// JsonMarshalSafely tries to marshal the value, but returns `[]byte("<error>")` if it can't (instead of an error).
-func JsonMarshalSafely(v any) []byte {
-	jsonBytes, err := json.Marshal(v)
-	if err != nil {
-		return []byte("<error>")
-	}
-	return jsonBytes
-}
-
-func SanitizeFields(fields []zapcore.Field, hasher func(any) string) string {
+func SanitizeFields(fields []zapcore.Field, hasher func(any) string) map[string]string {
 	if hasher == nil {
 		hasher = func(_ any) string { return `<redacted>` }
 	}
-	safeLogs := make([]SanitizedField, 0, len(fields))
+	result := make(map[string]string)
 	for _, field := range fields {
 		if safeField, isSafe := field.Interface.(Sanitizer); isSafe {
-			safeLogs = append(safeLogs, safeField.Sanitize(hasher))
+			sanitizedField := safeField.Sanitize(hasher)
+			for k, v := range sanitizedField.Content {
+				result[fmt.Sprintf("%s.%s", sanitizedField.Key, k)] = v
+			}
 		}
 	}
-	return string(JsonMarshalSafely(safeLogs))
+	return result
 }
diff --git a/pkg/provider/aws/config.go b/pkg/provider/aws/config.go
index 4b7026f5..49a93a05 100644
--- a/pkg/provider/aws/config.go
+++ b/pkg/provider/aws/config.go
@@ -22,6 +22,7 @@ type (
 		ALBs                    []provider.Gateway
 		Buckets                 []provider.FS
 		SecretManagerSecrets    []provider.Config
+		RdsInstances            []provider.ORM
 	}
 )
 
@@ -55,7 +56,7 @@ const (
 	eks                                = "eks"
 	ecs                                = "ecs"
 	lambda                             = "lambda"
-	rds_postgres                       = "rds_postgres"
+	Rds_postgres                       = "rds_postgres"
 	Secrets_manager                    = "secrets_manager"
 	s3                                 = "s3"
 	dynamodb                           = "dynamodb"
@@ -116,9 +117,9 @@ var defaultConfig = config.Defaults{
 			Type: s3,
 		},
 		ORM: config.KindDefaults{
-			Type: rds_postgres,
+			Type: Rds_postgres,
 			InfraParamsByType: map[string]config.InfraParams{
-				rds_postgres: {
+				Rds_postgres: {
 					"instanceClass":     "db.t4g.micro",
 					"allocatedStorage":  20,
 					"skipFinalSnapshot": true,
@@ -167,7 +168,7 @@ func (a *AWS) GetKindTypeMappings(kind string) ([]string, bool) {
 	case string(core.PersistKVKind):
 		return []string{dynamodb}, true
 	case string(core.PersistORMKind):
-		return []string{rds_postgres}, true
+		return []string{Rds_postgres}, true
 	case string(core.PersistRedisNodeKind):
 		return []string{elasticache}, true
 	case string(core.PersistRedisClusterKind):
diff --git a/pkg/provider/aws/infra_template.go b/pkg/provider/aws/infra_template.go
index 03e60d9e..16101e04 100644
--- a/pkg/provider/aws/infra_template.go
+++ b/pkg/provider/aws/infra_template.go
@@ -124,13 +124,19 @@ func (a *AWS) Transform(result *core.CompilationResult, deps *core.Dependencies)
 				data.HasKV = true
 			}
 			if res.Kind == core.PersistORMKind {
-				data.ORMs = append(data.ORMs, provider.ORM{
-					Name:   res.Name,
-					Type:   cfg.Type,
-					Params: cfg.InfraParams,
-				})
 				if cfg.Type == "rds_postgres" {
+					data.RdsInstances = append(data.RdsInstances, provider.ORM{
+						Name:   res.Name,
+						Type:   cfg.Type,
+						Params: cfg.InfraParams,
+					})
 					data.UseVPC = true
+				} else {
+					data.ORMs = append(data.ORMs, provider.ORM{
+						Name:   res.Name,
+						Type:   cfg.Type,
+						Params: cfg.InfraParams,
+					})
 				}
 			}
 			if res.Kind == core.PersistRedisClusterKind || res.Kind == core.PersistRedisNodeKind {


Test patch:
diff --git a/pkg/logging/safelogging_test.go b/pkg/logging/safelogging_test.go
index 86c6e406..e8d0cd44 100644
--- a/pkg/logging/safelogging_test.go
+++ b/pkg/logging/safelogging_test.go
@@ -12,7 +12,7 @@ func TestSanitize(t *testing.T) {
 		name   string
 		given  []zap.Field
 		hasher func(any) string
-		want   string
+		want   map[string]string
 	}{
 		{
 			name: "non-sanitized field",
@@ -21,7 +21,7 @@ func TestSanitize(t *testing.T) {
 					Interface: "not a safe field",
 				},
 			},
-			want: "[]",
+			want: map[string]string{},
 		},
 		{
 			name: "sanitized field unhashed",
@@ -30,7 +30,9 @@ func TestSanitize(t *testing.T) {
 					Interface: sanitizedString("safe to send"),
 				},
 			},
-			want: `[{"key":"TestSanitizedField","content":"safe to send"}]`,
+			want: map[string]string{
+				"SanitizedField.sanitized": "safe to send",
+			},
 		},
 		{
 			name: "hashed field",
@@ -40,7 +42,9 @@ func TestSanitize(t *testing.T) {
 				},
 			},
 			hasher: func(s any) string { return "this is a hash" },
-			want:   `[{"key":"TestHashingField","content":"this is a hash"}]`,
+			want: map[string]string{
+				"HashingField.hashed": "this is a hash",
+			},
 		},
 		{
 			name: "hashed field no hasher",
@@ -49,17 +53,19 @@ func TestSanitize(t *testing.T) {
 					Interface: hashingString("12345"),
 				},
 			},
-			want: `[{"key":"TestHashingField","content":"\u003credacted\u003e"}]`, // 0x3c is `<`, and 0x3e is `>`
+			want: map[string]string{
+				"HashingField.hashed": "<redacted>",
+			},
 		},
 		{
 			name:  "no fields",
 			given: []zap.Field{},
-			want:  "[]",
+			want:  map[string]string{},
 		},
 		{
 			name:  "nil fields",
 			given: []zap.Field{},
-			want:  "[]",
+			want:  map[string]string{},
 		},
 	}
 	for _, tt := range cases {
@@ -78,14 +84,14 @@ type (
 
 func (s sanitizedString) Sanitize(hasher func(any) string) SanitizedField {
 	return SanitizedField{
-		Key:     "TestSanitizedField",
-		Content: string(s),
+		Key:     "SanitizedField",
+		Content: map[string]string{"sanitized": string(s)},
 	}
 }
 
 func (s hashingString) Sanitize(hasher func(any) string) SanitizedField {
 	return SanitizedField{
-		Key:     "TestHashingField",
-		Content: hasher(string(s)),
+		Key:     "HashingField",
+		Content: map[string]string{"hashed": hasher(string(s))},
 	}
 }