# Copyright (c) 2026 Paul Butcher. All rights reserved. # Released under Apache 2.0 license as described in the file LICENSE. AWSTemplateFormatVersion: "2010-09-09" Transform: AWS::Serverless-2016-10-31 Description: TodoMVC in Lean, on a Lambda function URL backed by RDS Postgres. Parameters: FunctionMemory: Type: Number Default: 1024 Description: >- A parameter rather than a literal because the telemetry resource reports it as `faas.max_memory`, and a second copy of the number would be free to drift from the first. LogRetentionDays: Type: Number Default: 30 Description: >- How long the log group keeps what the function writes. Spans are stored there rather than merely reported there, so this is the retention of the telemetry itself and not of a transcript of it. CloudWatch accepts only certain values: 1, 3, 5, 7, 14, 30 and upwards. BaseUrl: Type: String Default: "" Description: >- The origin a sign-in link points back at, which is this function's own URL. It cannot be read from the function URL resource here: that resource depends on the function, so the function's environment depending on it in turn is a cycle CloudFormation refuses. Deploy once with this empty, read `Endpoint` from the outputs, and deploy again with it set. Sign-in is refused until it is. A trailing slash, which is how that output is published, is trimmed rather than concatenated into a link that would then 404. MailFrom: Type: String Description: >- The address sign-in mail is sent from. Its domain needs SPF, DKIM, DMARC and an MX record (a null MX will do) before mail is delivered rather than filed as spam. MailReplyTo: Type: String Default: "" Description: >- Where a reply to sign-in mail goes. Empty leaves it with the sending address, which for a no-reply sender means replies are read by nobody. It needs no SES identity of its own: only the address mail is sent from does. BedrockModel: Type: String Default: "" Description: >- Which model the assistant panel talks to, as Bedrock's Converse API names it. Empty leaves the client's own default in place. A model has to be enabled in this region before it will answer, and the id an account uses may be a cross-region inference profile rather than a foundation model, so this is deliberately not settled in the application. PepperKeyId: Type: String Default: "1" Description: >- Names the pepper that digested a credential, and is recorded alongside each one. Change it whenever the pepper secret changes, or the record of which key was used stops being true. SealingKeyId: Type: String Default: "1" Description: >- Names the key a provider secret was sealed under, and travels inside the sealed value itself. Change it when the sealing key changes, and reseal every secret: a value sealed under a key this deployment no longer holds cannot be opened, and the provider it belongs to stops working. # The three federated providers. Each is offered only where its client id is set, so a # deployment that wants none leaves all of these alone and gets the emailed link by itself. # # The secrets are parameters rather than Secrets Manager entries because they are already # ciphertext: `lake exe auth-seal` binds each to this tenant, this provider and this field # before it ever reaches here, and what opens them is `SealingKey` below, which is a secret and # is deliberately not a parameter. A sealed value in a parameter file discloses nothing without # it, which stops being true the moment the two travel together. GoogleClientId: Type: String Default: "" Description: The OAuth client id Google issued, or empty to not offer Google. GoogleClientSecret: Type: String Default: "" Description: >- Google's client secret, as `lake exe auth-seal seal todomvc google client-secret ` renders it. AppleClientId: Type: String Default: "" Description: >- The Services ID registered with Apple, which is what Apple calls a client id, or empty to not offer Apple. Sign in with Apple cannot be exercised over plain http, so this is one a deployment can have and a development machine cannot. AppleTeamId: Type: String Default: "" Description: The Apple developer team the Services ID belongs to. AppleKeyId: Type: String Default: "" Description: Which of the team's private keys the signing key below is. AppleSigningKey: Type: String Default: "" Description: >- Apple's `.p8`, as `lake exe auth-seal seal todomvc apple signing-key < key.p8` renders it. A key rather than a secret because Apple's client secret is minted per request and never stored. GitHubClientId: Type: String Default: "" Description: The OAuth app client id GitHub issued, or empty to not offer GitHub. GitHubClientSecret: Type: String Default: "" Description: >- GitHub's client secret, as `lake exe auth-seal seal todomvc github client-secret ` renders it. Resources: # Both the function and the database sit in this VPC so the database needs no public address. VPC: Type: AWS::EC2::VPC Properties: CidrBlock: 10.0.0.0/16 # Both are needed before an interface endpoint's private DNS resolves, and a VPC that is not # the default one has hostnames off. Without them `email..amazonaws.com` keeps # resolving to the public address, which nothing in here can reach. EnableDnsSupport: true EnableDnsHostnames: true # RDS insists on subnets in two availability zones even for a single-AZ instance. Subnet1: Type: AWS::EC2::Subnet Properties: VpcId: !Ref VPC CidrBlock: 10.0.1.0/24 AvailabilityZone: !Select [0, !GetAZs ""] Subnet2: Type: AWS::EC2::Subnet Properties: VpcId: !Ref VPC CidrBlock: 10.0.2.0/24 AvailabilityZone: !Select [1, !GetAZs ""] # Federated sign-in is the only thing here that talks to a host AWS does not run: a provider's # discovery document, its key set, and the token exchange. None of those has an interface # endpoint to resolve to, so they need a public egress path, which is what everything down to # `Subnet2RouteTableAssociation` builds. # # The function stays in the private subnets. Egress for it means a route to a NAT gateway, and # the NAT gateway is what holds the public address; a Lambda function never has one of its own. InternetGateway: Type: AWS::EC2::InternetGateway InternetGatewayAttachment: Type: AWS::EC2::VPCGatewayAttachment Properties: VpcId: !Ref VPC InternetGatewayId: !Ref InternetGateway # Public in that its route table reaches the internet gateway. Nothing is deployed into it but # the NAT gateway. PublicSubnet: Type: AWS::EC2::Subnet Properties: VpcId: !Ref VPC CidrBlock: 10.0.3.0/24 AvailabilityZone: !Select [0, !GetAZs ""] PublicRouteTable: Type: AWS::EC2::RouteTable Properties: VpcId: !Ref VPC PublicRoute: Type: AWS::EC2::Route # A route to a gateway cannot be created before the gateway is attached to the VPC. DependsOn: InternetGatewayAttachment Properties: RouteTableId: !Ref PublicRouteTable DestinationCidrBlock: 0.0.0.0/0 GatewayId: !Ref InternetGateway PublicSubnetRouteTableAssociation: Type: AWS::EC2::SubnetRouteTableAssociation Properties: SubnetId: !Ref PublicSubnet RouteTableId: !Ref PublicRouteTable NatEip: Type: AWS::EC2::EIP DependsOn: InternetGatewayAttachment Properties: Domain: vpc # One, rather than one per availability zone. A second would remove the only shared point of # failure this adds, and it would double a standing charge to carry a few requests per sign-in. # What it costs as it stands is that a sign-in served from the second zone crosses zones to get # out. NatGateway: Type: AWS::EC2::NatGateway Properties: AllocationId: !GetAtt NatEip.AllocationId SubnetId: !Ref PublicSubnet # The private subnets would otherwise use the VPC's main route table, which carries the local # route and nothing else. Both of them are moved onto this one. PrivateRouteTable: Type: AWS::EC2::RouteTable Properties: VpcId: !Ref VPC PrivateRoute: Type: AWS::EC2::Route Properties: RouteTableId: !Ref PrivateRouteTable DestinationCidrBlock: 0.0.0.0/0 NatGatewayId: !Ref NatGateway Subnet1RouteTableAssociation: Type: AWS::EC2::SubnetRouteTableAssociation Properties: SubnetId: !Ref Subnet1 RouteTableId: !Ref PrivateRouteTable Subnet2RouteTableAssociation: Type: AWS::EC2::SubnetRouteTableAssociation Properties: SubnetId: !Ref Subnet2 RouteTableId: !Ref PrivateRouteTable SecurityGroup: Type: AWS::EC2::SecurityGroup Properties: GroupDescription: !Sub "Security group for ${AWS::StackName}" VpcId: !Ref VPC SecurityGroupIngress: - IpProtocol: tcp FromPort: 5432 ToPort: 5432 CidrIp: !GetAtt VPC.CidrBlock # How the function reaches SES. The NAT gateway would carry it, but an interface endpoint keeps # the traffic on the AWS network and off a path that is charged by the gigabyte. # # If the stack fails to create this, the two things to check are that SES publishes an API # endpoint in the region: # # aws ec2 describe-vpc-endpoint-services \ # --filters Name=service-name,Values=com.amazonaws..email # # and that both subnets are in availability zones it serves. Either way it fails while # deploying rather than at the first sign-in, which is the failure worth having. SesEndpointSecurityGroup: Type: AWS::EC2::SecurityGroup Properties: GroupDescription: !Sub "SES interface endpoint for ${AWS::StackName}" VpcId: !Ref VPC SecurityGroupIngress: - IpProtocol: tcp FromPort: 443 ToPort: 443 CidrIp: !GetAtt VPC.CidrBlock SesEndpoint: Type: AWS::EC2::VPCEndpoint Properties: VpcId: !Ref VPC ServiceName: !Sub "com.amazonaws.${AWS::Region}.email" VpcEndpointType: Interface # What makes the hostname the signed request already names resolve here, so nothing in the # application knows the difference. PrivateDnsEnabled: true SubnetIds: [!Ref Subnet1, !Ref Subnet2] SecurityGroupIds: [!Ref SesEndpointSecurityGroup] # The assistant reaches Bedrock the same way mail reaches SES, and for the same reason: a turn is # the largest traffic this application carries, and it has an endpoint to stay inside. # # Bedrock has two endpoint services and this is the one that carries inference: `.bedrock` is # the control plane, and an endpoint for it would deploy cleanly and still refuse every turn. # # As with SES, check the region offers it and that both subnets are in zones it serves: # # aws ec2 describe-vpc-endpoint-services \ # --filters Name=service-name,Values=com.amazonaws..bedrock-runtime BedrockEndpointSecurityGroup: Type: AWS::EC2::SecurityGroup Properties: GroupDescription: !Sub "Bedrock runtime interface endpoint for ${AWS::StackName}" VpcId: !Ref VPC SecurityGroupIngress: - IpProtocol: tcp FromPort: 443 ToPort: 443 CidrIp: !GetAtt VPC.CidrBlock BedrockEndpoint: Type: AWS::EC2::VPCEndpoint Properties: VpcId: !Ref VPC ServiceName: !Sub "com.amazonaws.${AWS::Region}.bedrock-runtime" VpcEndpointType: Interface PrivateDnsEnabled: true SubnetIds: [!Ref Subnet1, !Ref Subnet2] SecurityGroupIds: [!Ref BedrockEndpointSecurityGroup] DBSubnetGroup: Type: AWS::RDS::DBSubnetGroup Properties: DBSubnetGroupDescription: !Sub "Subnet group for ${AWS::StackName}" SubnetIds: [!Ref Subnet1, !Ref Subnet2] DatabaseKey: Type: AWS::KMS::Key Properties: Description: !Sub "Storage encryption key for ${AWS::StackName}" EnableKeyRotation: false KeyPolicy: Version: "2012-10-17" Id: !Sub "key-${AWS::StackName}" Statement: - Effect: Allow Principal: AWS: !Sub "arn:${AWS::Partition}:iam::${AWS::AccountId}:root" Action: ["kms:*"] Resource: "*" Database: Type: AWS::RDS::DBInstance # CloudFormation's default for a database instance, spelled out rather than left implicit: # deleting the stack takes a final snapshot instead of taking the data with it. DeletionPolicy: Snapshot UpdateReplacePolicy: Snapshot Properties: DBInstanceClass: db.t4g.micro Engine: postgres # Major version only, so RDS picks a minor version it actually offers. 18 matches both the # Postgres the test suite runs against and the libpq the image links. EngineVersion: "18" DBName: todomvc AllocatedStorage: 20 StorageEncrypted: true KmsKeyId: !Ref DatabaseKey # Not `ManageMasterUserPassword`, which would have RDS generate this and rotate it every # seven days. # # The function reaches the database through `PGPASSWORD`, and a dynamic reference in a # function's environment is resolved when the stack is updated and the result written into # the function's configuration. It is a copy of the secret rather than a view of it, and # nothing refreshes the copy when the secret changes. A rotating password behind one works # until the first rotation and then fails every connection, a week after a deployment and # nowhere near it, with `password authentication failed` and nothing to say why. # # So the password is held here and does not rotate, and the two secrets below are held on the # same terms for the same reason. What contains it is the database having no public address # and only this VPC reaching its port. Rotating it would mean reading it at runtime. ManageMasterUserPassword: false MasterUsername: postgres MasterUserPassword: !Sub "{{resolve:secretsmanager:${DatabasePassword}:SecretString}}" VPCSecurityGroups: [!Ref SecurityGroup] DBSubnetGroupName: !Ref DBSubnetGroup # What the database above authenticates and the function below presents. # # RDS restricts what a master password may contain, so punctuation is excluded rather than # picked through: 64 characters of letters and digits is far more than the entropy any of this # rests on, and none of the excluded characters is load bearing. DatabasePassword: Type: AWS::SecretsManager::Secret Properties: Description: !Sub "Database master password for ${AWS::StackName}" GenerateSecretString: PasswordLength: 64 ExcludePunctuation: true # Sessions are sealed rather than stored, so the key has to outlive any one execution # environment and be identical across all of them. SessionKey: Type: AWS::SecretsManager::Secret Properties: Description: !Sub "Session sealing key for ${AWS::StackName}" GenerateSecretString: # 64 characters confined to [0-9a-f], which decode to the 32 bytes AES-256 needs. PasswordLength: 64 ExcludeUppercase: true ExcludePunctuation: true ExcludeCharacters: "ghijklmnopqrstuvwxyz" # The authentication library stores no credential, only an HMAC of one under this key. It has # the same standing as the session key above and one more consequence: replacing it without # keeping the old one alongside signs everyone out and voids every link in flight. AuthPepper: Type: AWS::SecretsManager::Secret Properties: Description: !Sub "Credential pepper for ${AWS::StackName}" GenerateSecretString: PasswordLength: 64 ExcludeUppercase: true ExcludePunctuation: true ExcludeCharacters: "ghijklmnopqrstuvwxyz" # Opens every sealed provider secret, and so must not live where those secrets do, which is why # it is here rather than beside them in the parameters. # # The placeholder is what this can generate and a key is not: a key is 32 bytes in base64url, # and `GenerateSecretString` draws characters from an alphabet, which cannot produce the final # character of a 43-character encoding. So a deployment that offers a provider writes the real # key here with `aws secretsmanager put-secret-value` and deploys again to pick it up. One that # offers none leaves the placeholder alone: nothing reads this until a provider is configured. SealingKey: Type: AWS::SecretsManager::Secret Properties: Description: !Sub "Provider secret sealing key for ${AWS::StackName}" # Not base64url at all, so a deployment that configures a provider and forgets to write the # key is refused at startup rather than opening nothing and saying why later. SecretString: "unset" # Declared rather than left to Lambda to create on first use, so that retention is this # template's decision and so that the saved queries and the dashboard have something to be # written against. The name is the one Lambda would have chosen anyway, which is what keeps # `sam logs` and everything else that assumes the convention pointed at it, and it is under # `/aws/lambda/`, which is the prefix the execution role's managed policy allows writing to. LogGroup: Type: AWS::Logs::LogGroup Properties: LogGroupName: !Sub "/aws/lambda/${AWS::StackName}" RetentionInDays: !Ref LogRetentionDays Function: Type: AWS::Serverless::Function # An invocation arriving before the log group exists would have Lambda create it, and then # CloudFormation would fail to create its own. DependsOn: LogGroup Properties: # Named rather than left to CloudFormation to generate one, because the log group above # has to be the group Lambda writes to by default, and that name is built from this one. # It also makes the `faas.name` the resource reports the name the function actually has. FunctionName: !Ref AWS::StackName PackageType: Image Architectures: [arm64] MemorySize: !Ref FunctionMemory Timeout: 20 # Every warm instance holds a Postgres connection open, and db.t4g.micro allows on the # order of a hundred in total. ReservedConcurrentExecutions: 20 FunctionUrlConfig: AuthType: NONE VpcConfig: SecurityGroupIds: [!Ref SecurityGroup] SubnetIds: [!Ref Subnet1, !Ref Subnet2] Policies: - AWSLambdaVPCAccessExecutionRole # Any identity in this account and region rather than the one `MailFrom` names, because # the identity behind an address may be the address or its domain, and naming the wrong # one gives a stack that deploys and then refuses the first sign-in. - Statement: - Effect: Allow Action: [ses:SendEmail] Resource: !Sub "arn:${AWS::Partition}:ses:${AWS::Region}:${AWS::AccountId}:identity/*" # Both forms, because `BedrockModel` may name either: an inference profile is reached # through its own ARN and fans out to foundation models, so granting the profile alone # fails on the model behind it. Any model in the region rather than the one named, for # the reason SES above is granted every identity: a policy that has to be changed in step # with a parameter deploys cleanly and then refuses the first turn. - Statement: - Effect: Allow Action: [bedrock:InvokeModel] Resource: - !Sub "arn:${AWS::Partition}:bedrock:*::foundation-model/*" - !Sub "arn:${AWS::Partition}:bedrock:${AWS::Region}:${AWS::AccountId}:inference-profile/*" Environment: Variables: # `Postgres.open ""` passes an empty connection string to libpq, which then reads all # of these itself. Nothing in the application needs to know where they came from. PGHOST: !GetAtt Database.Endpoint.Address PGPORT: !Sub "${Database.Endpoint.Port}" PGDATABASE: todomvc PGUSER: postgres PGPASSWORD: !Sub "{{resolve:secretsmanager:${DatabasePassword}:SecretString}}" # libpq defaults to `prefer`, which falls back to an unencrypted connection without # complaint. `verify-full` would additionally need the RDS CA bundle in the image. PGSSLMODE: require SESSION_KEY: !Sub "{{resolve:secretsmanager:${SessionKey}:SecretString}}" AUTH_PEPPER: !Sub "{{resolve:secretsmanager:${AuthPepper}:SecretString}}" AUTH_PEPPER_KEY_ID: !Ref PepperKeyId AUTH_SEALING_KEY: !Sub "{{resolve:secretsmanager:${SealingKey}:SecretString}}" AUTH_SEALING_KEY_ID: !Ref SealingKeyId GOOGLE_CLIENT_ID: !Ref GoogleClientId GOOGLE_CLIENT_SECRET: !Ref GoogleClientSecret APPLE_CLIENT_ID: !Ref AppleClientId APPLE_TEAM_ID: !Ref AppleTeamId APPLE_KEY_ID: !Ref AppleKeyId APPLE_SIGNING_KEY: !Ref AppleSigningKey GITHUB_CLIENT_ID: !Ref GitHubClientId GITHUB_CLIENT_SECRET: !Ref GitHubClientSecret BASE_URL: !Ref BaseUrl MAIL_FROM: !Ref MailFrom MAIL_REPLY_TO: !Ref MailReplyTo BEDROCK_MODEL: !Ref BedrockModel OTEL_SERVICE_NAME: todomvc # Telemetry leaves as JSON on stdout, which the runtime forwards to CloudWatch Logs. # That path belongs to the Lambda service rather than to this function's network # interface, so it crosses neither the NAT gateway nor this VPC, and it is why there is # no collector here. Reaching anything beyond CloudWatch is a subscription filter's job, # and that also runs outside this VPC. # # `flat_json` rather than `otlp_json` because Logs Insights has to be able to address a # field, and an OTLP envelope buries an attribute at an index that varies per span and # states the resource once per batch rather than on the row. One self-contained object # per span is the difference between a log group that can be queried like a trace store # and one that is a transcript. # # Lambda's own JSON log format has to stay off for any of that to hold: it wraps each # line in an envelope, and a span's fields would arrive nested under `message` rather # than as fields of the row. # # Giving stdout to the machine costs the readable format. `lake exe logs` renders it # back for a terminal, and a developer running locally sets none of this and has it # directly. OTEL_TRACES_EXPORTER: console OTEL_LOGS_EXPORTER: console OTEL_EXPORTER_CONSOLE_FORMAT: flat_json # What is settled at deployment. `faas.instance` is not, and the function supplies it # itself; see `LambdaMain.instanceAttrs`. `host.name` goes undetected either way, since # Lambda sets no `HOSTNAME`. OTEL_RESOURCE_ATTRIBUTES: !Sub - "cloud.provider=aws,cloud.platform=aws_lambda,cloud.region=${AWS::Region},faas.name=${Name},faas.max_memory=${Memory}" - Name: !Sub "${AWS::StackName}" Memory: !Ref FunctionMemory Metadata: Dockerfile: Dockerfile DockerContext: . DockerTag: latest # The analysis loop, saved so that a reader of a fresh deployment finds it rather than has to # know it exists. Each is a step: notice something, break it down by whatever looks likely, # pick out the ones that are wrong, then read a single trace end to end. # # Nothing here aggregates in advance, which is the whole reason the function writes one flat # object per span: any field on a row can be grouped by, at any cardinality, when the question # is asked rather than when the code was written. SlowestRoutesQuery: Type: AWS::Logs::QueryDefinition Properties: Name: !Sub "${AWS::StackName}/Slowest routes" LogGroupNames: [!Ref LogGroup] QueryString: | filter meta.signal_type = "trace" and span.kind = "server" | stats count(*) as requests, pct(duration_ms, 50) as p50, pct(duration_ms, 99) as p99 by http.route | sort p99 desc SlowestRequestsQuery: Type: AWS::Logs::QueryDefinition Properties: Name: !Sub "${AWS::StackName}/Slowest requests" LogGroupNames: [!Ref LogGroup] QueryString: | filter meta.signal_type = "trace" and span.kind = "server" | fields time, name, duration_ms, http.response.status_code, trace.trace_id | sort duration_ms desc | limit 20 # Both signals at once, and in the order they happened: the spans give the shape of the request # and the log records give what it said while serving it. OneTraceQuery: Type: AWS::Logs::QueryDefinition Properties: Name: !Sub "${AWS::StackName}/One trace" LogGroupNames: [!Ref LogGroup] QueryString: | filter trace.trace_id = "paste a trace id here" | fields time, duration_ms, name, span.kind, body, trace.span_id, trace.parent_id | sort time asc # A span reports failure as OTLP status 2 and a log record as severity 17 or above, so asking # what went wrong means asking both. ErrorsQuery: Type: AWS::Logs::QueryDefinition Properties: Name: !Sub "${AWS::StackName}/Errors" LogGroupNames: [!Ref LogGroup] QueryString: | filter status_code = 2 or severity_code >= 17 | fields time, name, status_message, body, trace.trace_id | sort time desc | limit 50 # `migrate` runs in the function's init rather than in an invocation, so one of its spans is # one execution environment starting. Counting them counts cold starts, which the application # cannot otherwise see: it is frozen for the whole of the interval that decides them. ColdStartsQuery: Type: AWS::Logs::QueryDefinition Properties: Name: !Sub "${AWS::StackName}/Cold starts" LogGroupNames: [!Ref LogGroup] QueryString: | filter meta.signal_type = "trace" and name = "migrate" | stats count(*) as coldStarts, pct(duration_ms, 99) as p99 by bin(1h) # The same loop laid out to be glanced at. The one metric widget is there because it is the # only thing on the page the telemetry cannot supply: an invocation that failed to start, or # was throttled, or timed out, never wrote a span, and is counted by Lambda and nowhere else. TelemetryDashboard: Type: AWS::CloudWatch::Dashboard Properties: DashboardName: !Ref AWS::StackName DashboardBody: !Sub | { "widgets": [ { "type": "metric", "x": 0, "y": 0, "width": 24, "height": 6, "properties": { "title": "What the platform saw", "region": "${AWS::Region}", "view": "timeSeries", "stat": "Sum", "period": 300, "metrics": [ ["AWS/Lambda", "Invocations", "FunctionName", "${Function}"], [".", "Errors", ".", "."], [".", "Throttles", ".", "."] ] } }, { "type": "log", "x": 0, "y": 6, "width": 12, "height": 6, "properties": { "title": "Server latency", "region": "${AWS::Region}", "view": "timeSeries", "query": "SOURCE '${LogGroup}' | filter meta.signal_type = \"trace\" and span.kind = \"server\" | stats pct(duration_ms, 50) as p50, pct(duration_ms, 99) as p99 by bin(5m)" } }, { "type": "log", "x": 12, "y": 6, "width": 12, "height": 6, "properties": { "title": "p99 by route", "region": "${AWS::Region}", "view": "bar", "query": "SOURCE '${LogGroup}' | filter meta.signal_type = \"trace\" and span.kind = \"server\" | stats pct(duration_ms, 99) as p99 by http.route | sort p99 desc | limit 10" } }, { "type": "log", "x": 0, "y": 12, "width": 12, "height": 6, "properties": { "title": "Slowest requests", "region": "${AWS::Region}", "view": "table", "query": "SOURCE '${LogGroup}' | filter meta.signal_type = \"trace\" and span.kind = \"server\" | fields time, name, duration_ms, trace.trace_id | sort duration_ms desc | limit 20" } }, { "type": "log", "x": 12, "y": 12, "width": 12, "height": 6, "properties": { "title": "Errors", "region": "${AWS::Region}", "view": "table", "query": "SOURCE '${LogGroup}' | filter status_code = 2 or severity_code >= 17 | fields time, name, status_message, body, trace.trace_id | sort time desc | limit 20" } } ] } Outputs: Endpoint: Description: Public URL of the application. Value: !GetAtt FunctionUrl.FunctionUrl Dashboard: Description: Where the telemetry is read. Value: !Sub "https://${AWS::Region}.console.aws.amazon.com/cloudwatch/home?region=${AWS::Region}#dashboards/dashboard/${TelemetryDashboard}" SealingKeySecret: Description: >- Where the key that opens provider secrets lives, holding a placeholder until a deployment offers a provider. Write the key `lake exe auth-seal key` minted with `aws secretsmanager put-secret-value --secret-id --secret-string `, then deploy again: the function reads this at deploy time, not per request. Value: !Ref SealingKey FederatedCallback: Description: >- What to register with each provider, substituting google, apple or github for . Providers compare it as a string, so it has to match to the character. Value: !Sub "${FunctionUrl.FunctionUrl}t/todomvc/federated//callback"