Setting the file. One moment.
Template Main · AWS Marketplace Metering · aws/agent-toolkit-for-aws · Skills Docs
ContentsBack to the top of the page Query Patterns
70
Creating Amazon Aurora Db Cluster With Instances
93
Routing Traffic With Route53 And CloudFront
Resilience Program Design
Creating API Gateway Stage
Previous
Asset Template Events
assets/ template-main.yaml
YAML · 1,850 lines · 82 KB
:
full
12 AllowedValues : [ full , direct-submit ]
13 Description : >-
14 full: the seller writes RAW per-second usage rows and the pipeline
15 (discoverer/aggregator/cleanup) aggregates them. direct-submit: the seller writes
16 FINALIZED hourly aggregated records straight into the aggregated_usage table; only the
17 submitter + submission-expiry are created (no raw usage table, work/cleanup queues, or
18 discoverer/aggregator/cleanup). In direct-submit mode a record MUST be final before
19 insert — any record present may be submitted on the next 5-minute run, and a second
20 write for the same (licenseArn, account, dimension, hour) returns DuplicateRecord.
21 MeteringMode :
22 Type : String
23 Default : live
24 AllowedValues : [ live , dry-run ]
25 Description : >-
26 live (production): the submitter calls the real BatchMeterUsage (real billing).
27 dry-run (non-production sandbox): the submitter runs the FULL pipeline + client-side
28 validation but NEVER calls BatchMeterUsage — it logs/EMF-emits the records it would have
29 sent and writes meteringStatus=DryRunSubmitted. A dry-run stage REQUIRES a
30 TestAccountAllowlist and may deploy the test event publisher; a live stage does neither.
31 Prod vs non-prod is decided by THIS switch, NOT by the free-form StageName.
32 TestAccountAllowlist :
33 Type : CommaDelimitedList
34 Default : ''
35 Description : >-
36 For a dry-run (non-prod) stage ONLY: the seller's TEST buyer AWS account IDs. The
37 non-prod events rule + test event publisher are scoped to these accounts so a non-prod
38 stack never processes production buyers. MUST be non-empty when MeteringMode=dry-run and
39 MUST be empty when MeteringMode=live (enforced by deploy.sh).
40 StackPrefix :
41 Type : String
42 Default : awsmp
43 Description : Prefix for resource names to avoid collisions
44 SubscribersTableName :
45 Type : String
46 Description : Name of the unified subscribers table in us-east-1 (from events stack output)
47 AlertsTopicArn :
48 Type : String
49 Default : ''
50 Description : >-
51 Optional SNS topic ARN (in THIS region) for metering error alarms. A CloudWatch
52 alarm can only notify an SNS topic in its own region, so this must be a
53 seller-region topic — not the us-east-1 events-stack topic. Leave empty to skip.
54 StageName :
55 Type : String
56 AllowedPattern : '^[A-Za-z0-9_-]+$'
57 ConstraintDescription : >-
58 StageName is mandatory and must be a non-empty API Gateway stage name
59 (letters, digits, '-' or '_'), e.g. 'v1', 'live', or 'prod'.
60 Description : >-
61 API Gateway stage name for the registration endpoint. TODO(seller): this is a
62 MANDATORY value you must supply (e.g. `v1`, `live`, or `prod` — your choice). There
63 is NO default and no fallback to `prod`: the parameter has no Default, so a direct
64 `sam deploy` with no override fails fast at the template level (and AllowedPattern
65 rejects an empty/invalid value) rather than creating a malformed empty stage.
66 deploy.sh performs the same non-empty check before deploying.
67 WebAclArn :
68 Type : String
69 Default : ''
70 Description : >-
71 Optional override: ARN of a seller-supplied AWS WAFv2 WebACL (REGIONAL scope) to
72 associate with the registration API stage instead of the baseline WebACL this stack
73 creates. Leave empty to use the built-in baseline WebACL (rate-based + common-exploit
74 rules) that the stack creates and attaches by default.
75 LogsKmsKeyArn :
76 Type : String
77 Default : ''
78 Description : >-
79 Optional KMS key ARN to encrypt the Lambda + API access log groups (logs contain
80 customer AWS account IDs / license ARNs). Leave empty for CloudWatch
81 default encryption. The key policy MUST allow logs.<region>.amazonaws.com.
82 LogRetentionInDays :
83 Type : Number
84 Default : 90
85 Description : Retention (days) for the Register/Meter Lambda and API access log groups.
86 MeteringLockHours :
87 Type : Number
88 Default : 1
89 MinValue : 1
90 MaxValue : 20
91 Description : >-
92 How many hours a metering hour stays OPEN for late usage before it is
93 aggregated and submitted. The discoverer's meter mode no-ops for hour offsets below
94 this value, so an hour is first submitted at age MeteringLockHours. Default 1 (submit
95 a fully-complete hour: at 12:20 only 11:00-11:59:59 is processed). Bounded 1..20: an
96 hour is first submitted at age MeteringLockHours, leaving 24 - MeteringLockHours hours
97 inside the 24h billable window before age-out, so the cap of 20 leaves >=4h of
98 margin — room for >=3 hourly submission retries of a transient failure AND for the
99 seller to inspect + re-drive a client-side rejection before the hour ages out at 24h.
100 TODO(seller): ask the seller how long to keep an hour open for late events.
101 UsageTableTtlDays :
102 Type : Number
103 Default : 365
104 MinValue : 0
105 MaxValue : 3650
106 Description : >-
107 TTL retention (in DAYS) for RAW usage-table rows, via a numeric `ttl` epoch-seconds
108 attribute. The raw usage table is the HIGH-VOLUME table (one row per second per group),
109 so a TTL is RECOMMENDED here to control storage cost. Set 0 to DISABLE TTL entirely
110 (the TimeToLiveSpecification is then omitted via a CloudFormation Condition).
111 CORRECTNESS FLOOR: when ENABLED the value must exceed MeteringLockHours + the 24h
112 age-out window with margin, so the accepted set is 0 (disabled) OR >= 2 days — a
113 value of 1 is rejected by deploy.sh (CloudFormation MinValue cannot express a disjoint
114 "0 or >= 2" range, so MinValue is 0 here and the >= 2 floor is enforced in deploy.sh).
115 IMPORTANT: the seller's usage WRITER sets the `ttl` attribute (writer logic is out of
116 scope); enabling TTL here only makes DynamoDB honor a `ttl` the writer populates. If
117 the writer does not set `ttl`, no raw row expires (TTL is a no-op) — that is safe, just
118 not cost-optimal. TODO(seller): confirm the retention that satisfies your audit needs.
119 AggregatedUsageTableTtlDays :
120 Type : Number
121 Default : 0
122 MinValue : 0
123 MaxValue : 3650
124 Description : >-
125 TTL retention (in DAYS) for the AGGREGATED usage table, via a numeric `ttl`
126 epoch-seconds attribute the submitter sets on FINALIZED rows. Defaults to 0 (DISABLED
127 = retain), which is RECOMMENDED: this table is one small row per group per hour (not
128 high-volume) and is a billing AUDIT trail (MeteringRecordId + status), so keeping it
129 aids auditing/debugging. Set a LARGE value only if you want eventual pruning; the
130 submitter sets `ttl` ONLY on rows that reached a terminal Success state, never on
131 pending/failed rows. TODO(seller): confirm whether aggregated-usage pruning is wanted.
132 CreateDashboard :
133 Type : String
134 Default : 'true'
135 AllowedValues : [ 'true' , 'false' ]
136 Description : >-
137 Whether to create the per-product CloudWatch HEALTH dashboard for the metering
138 pipeline (default true). The dashboard gives complete pipeline visibility in one place
139 (per-Lambda errors/throttles/invocations/duration, queue + DLQ depths + oldest-message
140 age, and the granular EMF error/status metrics) without drilling into individual
141 alarms. It is health-only (no business metrics). Set 'false' to skip it; you can also
142 delete or customize the dashboard after deployment.
143 AllowedRegistrationFields :
144 Type : String
145 Default : ''
146 Description : >-
147 Comma-separated allowlist of registration-form fields the Register Lambda may
148 persist. The public endpoint persists ONLY these fields, each
149 length-bounded. Leave empty to persist no custom fields. Example:
150 "company_name,email,team_size".
151 PromotedProfileFields :
152 Type : String
153 Default : ''
154 Description : >-
155 Comma-separated SUBSET of AllowedRegistrationFields to PROMOTE to top-level
156 attributes on the customer-profile table so a GSI can look profiles up by that
157 field (e.g. "email"). Each promoted field is written both into the registrationData
158 map AND as a top-level attribute; define a matching GSI in the CustomerProfileTable
159 TODO(seller) block. Leave empty for none. Example: "email,company_name".
160 PermissionsBoundaryName :
161 Type : String
162 Default : awsmp-metering-boundary
163 Description : >-
164 Name of the IAM permissions-boundary policy applied to the SAM-generated Lambda
165 execution roles. This MUST match the boundary the deployer role's iam:CreateRole
166 condition requires, and the boundary policy MUST already exist in the account
167 before deployment (see references/iam-credentials.md). The Lambda roles are
168 auto-generated by SAM from the inline Policies below; without this boundary,
169 role creation is denied by the deployer role.
170
171 Conditions :
172 HasAlertsTopic : !Not [ !Equals [ !Ref AlertsTopicArn , '' ]]
173 # Full pipeline (raw table + discoverer/aggregator/cleanup + work/cleanup queues) vs
174 # direct-submit (seller writes finalized records straight to aggregated_usage).
175 IsFullPipeline : !Equals [ !Ref DeploymentMode , full ]
176 IsDirectSubmit : !Equals [ !Ref DeploymentMode , direct-submit ]
177 # Non-production sandbox stage: submitter dry-runs (no real BatchMeterUsage) and the test
178 # event publisher is created. live = production (real billing).
179 IsDryRun : !Equals [ !Ref MeteringMode , dry-run ]
180 # TTL is included only when the retention (days) parameter is > 0 (0 = disabled).
181 UsageTableTtlEnabled : !And [ !Equals [ !Ref DeploymentMode , full ], !Not [ !Equals [ !Ref UsageTableTtlDays , 0 ]]]
182 AggregatedUsageTableTtlEnabled : !Not [ !Equals [ !Ref AggregatedUsageTableTtlDays , 0 ]]
183 CreateHealthDashboard : !Equals [ !Ref CreateDashboard , 'true' ]
184 CreateFullDashboard : !And [ !Equals [ !Ref CreateDashboard , 'true' ], !Equals [ !Ref DeploymentMode , full ]]
185 CreateDirectDashboard : !And [ !Equals [ !Ref CreateDashboard , 'true' ], !Equals [ !Ref DeploymentMode , direct-submit ]]
186 HasWebAcl : !Not [ !Equals [ !Ref WebAclArn , '' ]]
187 # When no seller WebACL ARN is provided, create + attach the built-in baseline WebACL.
188 UseBaselineWebAcl : !Equals [ !Ref WebAclArn , '' ]
189 HasLogsKmsKey : !Not [ !Equals [ !Ref LogsKmsKeyArn , '' ]]
190
191 Globals :
192 Function :
193 Runtime : python3.12
194 Timeout : 30
195 # Apply the permissions boundary to the SAM-auto-generated Lambda execution roles so
196 # iam:CreateRole succeeds under the deployer role (whose iam:CreateRole is gated on a
197 # matching iam:PermissionsBoundary). The boundary policy must exist before deployment.
198 PermissionsBoundary : !Sub arn:aws:iam::${AWS::AccountId}:policy/${PermissionsBoundaryName}
199 Environment :
200 Variables :
201 PRODUCT_CODE : !Ref ProductCode
202 SUBSCRIBERS_TABLE : !Ref SubscribersTableName
203 SUBSCRIBERS_TABLE_REGION : us-east-1
204 AGGREGATED_USAGE_TABLE : !Ref AggregatedUsageTable
205
206 Resources :
207 # In-region customer-profile table (buyer PII / registration data). Present in BOTH
208 # deployment modes (registration happens regardless of full vs direct-submit). Keeps buyer
209 # PII in THIS Region — the register Lambda writes the allowlisted registration fields here as
210 # a `registrationData` map (+ any promoted top-level fields), never to the us-east-1
211 # subscribers table (which stays PII-free). Keyed to match the subscriber identity.
212 CustomerProfileTable :
213 Type : AWS::DynamoDB::Table
214 DeletionPolicy : Retain
215 UpdateReplacePolicy : Retain
216 Properties :
217 TableName : !Sub ${StackPrefix}-customer-profile
218 BillingMode : PAY_PER_REQUEST
219 PointInTimeRecoverySpecification :
220 PointInTimeRecoveryEnabled : true
221 SSESpecification :
222 SSEEnabled : true
223 AttributeDefinitions :
224 - AttributeName : licenseArn
225 AttributeType : S
226 - AttributeName : customerAWSAccountId
227 AttributeType : S
228 # TODO(seller): for each PROMOTED registration field (PromotedProfileFields), add its
229 # AttributeDefinition here and a matching GSI below. Example for an `email` field:
230 # - AttributeName: email
231 # AttributeType: S
232 KeySchema :
233 - AttributeName : licenseArn
234 KeyType : HASH
235 - AttributeName : customerAWSAccountId
236 KeyType : RANGE
237 # TODO(seller): define one GSI per promoted registration field so profiles can be looked
238 # up by that field WITHOUT a Scan (the register Lambda writes each promoted field as a
239 # top-level attribute in addition to the registrationData map). Example:
240 # GlobalSecondaryIndexes:
241 # - IndexName: email-index
242 # KeySchema:
243 # - AttributeName: email
244 # KeyType: HASH
245 # Projection:
246 # ProjectionType: ALL
247 Tags :
248 - Key : ManagedBy
249 Value : marketplace-metering-skill
250
251 UsageTable :
252 Type : AWS::DynamoDB::Table
253 Condition : IsFullPipeline
254 DeletionPolicy : Retain
255 UpdateReplacePolicy : Retain
256 Properties :
257 TableName : !Sub ${StackPrefix}-usage
258 BillingMode : PAY_PER_REQUEST
259 PointInTimeRecoverySpecification :
260 PointInTimeRecoveryEnabled : true
261 SSESpecification :
262 SSEEnabled : true
263 # TTL (seller-configurable via UsageTableTtlDays; RECOMMENDED on this high-volume
264 # table to control cost). Only DynamoDB-honored; the seller's WRITER must set the
265 # numeric `ttl` epoch-seconds attribute (writer logic is out of scope), and MUST set
266 # it beyond MeteringLockHours + the 24h billable window so a row is never deleted
267 # before it is metered. Omitted entirely when UsageTableTtlDays = 0.
268 TimeToLiveSpecification : !If
269 - UsageTableTtlEnabled
270 - AttributeName : ttl
271 Enabled : true
272 - !Ref 'AWS::NoValue'
273 Tags :
274 - Key : ManagedBy
275 Value : marketplace-metering-skill
276 # Usage table key schema: licenseArn (HASH) + customerAWSAccountId_dimension_timestamp
277 # (RANGE). The RANGE attribute value is the '#'-delimited composite
278 # "{customerAWSAccountId}#{dimension}#{timestamp}" where timestamp is an exact
279 # SECOND-precision UTC value "YYYY-MM-DDTHH:MM:SS" (whole seconds, NOT millisecond/
280 # fractional and NOT hour-truncated). Second precision lets multiple usage rows exist
281 # for the same (licenseArn, customerAWSAccountId, dimension, hour) — each distinct
282 # second is a distinct row (bounded at <=3600 rows/hour). The aggregator validates the
283 # sort key at the fold and rejects a bad one client-side with EXACTLY ONE reason code
284 # per condition, checked in order: MalformedSortKey (empty or fewer than the 3
285 # '#'-segments) -> SortKeyMismatch (account or dimension segment != the row's own
286 # attribute) -> MalformedTimestamp (timestamp segment not exact whole-second
287 # YYYY-MM-DDTHH:MM:SS, incl. a millisecond/fractional or hour-truncated suffix).
288 # Write whole seconds only.
289 # customerAWSAccountId, dimension, and timestamp are ALSO stored as separate
290 # top-level attributes on each item so the pipeline reads them without parsing
291 # the sort key (in particular customerAWSAccountId is on every row, so a CA
292 # UsageRecord is built without a subscriber-table lookup). The aggregator reads a
293 # group via begins_with(sortKey, "{customerAWSAccountId}#{dimension}#{hourPrefix}")
294 # and aggregates the hour's rows into one UsageRecord.
295 # TODO(seller): your usage-writer (ingestion) must populate licenseArn, the composite
296 # sort key (whole-second UTC timestamp), and the customerAWSAccountId / dimension /
297 # timestamp / quantity attrs, plus set meteringPending to the "%Y-%m-%dT%H" hour bucket
298 # (matching the row's own timestamp hour) while the row awaits metering. Additional
299 # seller-defined attributes (e.g. eventId) are preserved. RECOMMENDED: stamp ISO-8601
300 # UTC createdAt (once, at insert) + updatedAt (on any rewrite) audit timestamps on each
301 # row; the pipeline stamps updatedAt when it finalizes a row.
302 AttributeDefinitions :
303 - AttributeName : licenseArn
304 AttributeType : S
305 - AttributeName : customerAWSAccountId_dimension_timestamp
306 AttributeType : S
307 - AttributeName : meteringPending
308 AttributeType : S
309 KeySchema :
310 - AttributeName : licenseArn
311 KeyType : HASH
312 - AttributeName : customerAWSAccountId_dimension_timestamp
313 KeyType : RANGE
314 GlobalSecondaryIndexes :
315 # metering_pending GSI: lets the meter Lambda find usage awaiting metering
316 # WITHOUT depending on the subscribers/registration table. Sellers who obtain
317 # the LicenseArn via EventBridge or SDDS (bypassing ResolveCustomer) still get
318 # metered. Set meteringPending="<hourBucket>" when writing usage; the meter
319 # Lambda queries this GSI, meters, then clears the attribute (removing the item
320 # from the sparse index).
321 - IndexName : metering_pending
322 KeySchema :
323 - AttributeName : meteringPending
324 KeyType : HASH
325 - AttributeName : licenseArn
326 KeyType : RANGE
327 Projection :
328 ProjectionType : ALL
329
330 # Aggregated-usage table: the submission source of truth for the decoupled
331 # metering pipeline. The Aggregator folds all raw usage rows of a
332 # (licenseArn, customerAWSAccountId, dimension, hour) group into ONE record here via a
333 # CONDITIONAL PutItem (attribute_not_exists(licenseArn)) — the idempotency commit point.
334 # The Submitter reads pending records via the metering_pending GSI, calls
335 # BatchMeterUsage, and writes meteringRecordId/meteringStatus + REMOVEs meteringPending
336 # ON THIS table (never the raw usage table). TTL is seller-configurable via
337 # AggregatedUsageTableTtlDays but DEFAULTS OFF (0): this is a small billing audit trail
338 # (MeteringRecordId + status), so retaining it aids auditing/debugging. When enabled, the
339 # submitter sets the `ttl` epoch ONLY on finalized Success rows.
340 AggregatedUsageTable :
341 Type : AWS::DynamoDB::Table
342 DeletionPolicy : Retain
343 UpdateReplacePolicy : Retain
344 Properties :
345 TableName : !Sub ${StackPrefix}-aggregated-usage
346 BillingMode : PAY_PER_REQUEST
347 PointInTimeRecoverySpecification :
348 PointInTimeRecoveryEnabled : true
349 SSESpecification :
350 SSEEnabled : true
351 TimeToLiveSpecification : !If
352 - AggregatedUsageTableTtlEnabled
353 - AttributeName : ttl
354 Enabled : true
355 - !Ref 'AWS::NoValue'
356 Tags :
357 - Key : ManagedBy
358 Value : marketplace-metering-skill
359 # PK licenseArn (HASH) + account#dimension#hour (RANGE). meteringPending (hour
360 # bucket) drives the submitter's sparse-GSI discovery, mirroring the raw usage table.
361 AttributeDefinitions :
362 - AttributeName : licenseArn
363 AttributeType : S
364 - AttributeName : account_dimension_hour
365 AttributeType : S
366 - AttributeName : meteringPending
367 AttributeType : S
368 KeySchema :
369 - AttributeName : licenseArn
370 KeyType : HASH
371 - AttributeName : account_dimension_hour
372 KeyType : RANGE
373 GlobalSecondaryIndexes :
374 - IndexName : metering_pending
375 KeySchema :
376 - AttributeName : meteringPending
377 KeyType : HASH
378 - AttributeName : licenseArn
379 KeyType : RANGE
380 Projection :
381 ProjectionType : ALL
382
383 # ── SQS: decoupled pipeline queues ─────────────────────────────────────
384 # Work queue (discoverer -> aggregator): one message per pending group. Standard queue
385 # (idempotency is enforced downstream by the aggregator's conditional PutItem).
386 MeteringWorkQueue :
387 Type : AWS::SQS::Queue
388 Condition : IsFullPipeline
389 Properties :
390 QueueName : !Sub ${StackPrefix}-metering-work
391 SqsManagedSseEnabled : true
392 VisibilityTimeout : 360 # >= 6x aggregator Timeout (60s) per SQS ESM guidance
393 RedrivePolicy :
394 deadLetterTargetArn : !GetAtt MeteringWorkDLQ.Arn
395 maxReceiveCount : 5
396 Tags :
397 - Key : ManagedBy
398 Value : marketplace-metering-skill
399
400 MeteringWorkDLQ :
401 Type : AWS::SQS::Queue
402 Condition : IsFullPipeline
403 Properties :
404 QueueName : !Sub ${StackPrefix}-metering-work-dlq
405 SqsManagedSseEnabled : true
406 MessageRetentionPeriod : 1209600 # 14 days
407 Tags :
408 - Key : ManagedBy
409 Value : marketplace-metering-skill
410
411 # Dedicated deprovision-work queue (flush-deprovisioning sweep -> aggregator). Separate from
412 # the regular work queue so a deprovisioning backlog is isolated from — and never queued
413 # behind — the regular hourly backlog; feeds the SAME aggregator function via its own ESM.
414 DeprovisionWorkQueue :
415 Type : AWS::SQS::Queue
416 Condition : IsFullPipeline
417 Properties :
418 QueueName : !Sub ${StackPrefix}-deprovision-work
419 SqsManagedSseEnabled : true
420 VisibilityTimeout : 360 # >= 6x aggregator Timeout (60s) per SQS ESM guidance
421 RedrivePolicy :
422 deadLetterTargetArn : !GetAtt DeprovisionWorkDLQ.Arn
423 maxReceiveCount : 5
424 Tags :
425 - Key : ManagedBy
426 Value : marketplace-metering-skill
427
428 DeprovisionWorkDLQ :
429 Type : AWS::SQS::Queue
430 Condition : IsFullPipeline
431 Properties :
432 QueueName : !Sub ${StackPrefix}-deprovision-work-dlq
433 SqsManagedSseEnabled : true
434 MessageRetentionPeriod : 1209600 # 14 days
435 Tags :
436 - Key : ManagedBy
437 Value : marketplace-metering-skill
438
439 # Cleanup queue (aggregator -> cleanup): each message carries <=100 raw row keys whose
440 # meteringPending must be cleared on the raw usage table.
441 MeteringCleanupQueue :
442 Type : AWS::SQS::Queue
443 Condition : IsFullPipeline
444 Properties :
445 QueueName : !Sub ${StackPrefix}-metering-cleanup
446 SqsManagedSseEnabled : true
447 VisibilityTimeout : 720 # >= 6x cleanup Timeout (120s) per SQS ESM guidance
448 RedrivePolicy :
449 deadLetterTargetArn : !GetAtt MeteringCleanupDLQ.Arn
450 maxReceiveCount : 5
451 Tags :
452 - Key : ManagedBy
453 Value : marketplace-metering-skill
454
455 MeteringCleanupDLQ :
456 Type : AWS::SQS::Queue
457 Condition : IsFullPipeline
458 Properties :
459 QueueName : !Sub ${StackPrefix}-metering-cleanup-dlq
460 SqsManagedSseEnabled : true
461 MessageRetentionPeriod : 1209600
462 Tags :
463 - Key : ManagedBy
464 Value : marketplace-metering-skill
465
466 RegistrationApi :
467 Type : AWS::Serverless::Api
468 Properties :
469 Name : !Sub ${StackPrefix}-registration-api
470 StageName : !Ref StageName
471 Auth :
472 DefaultAuthorizer : NONE
473 # API Gateway access logging: all requests to the public registration
474 # endpoint are auditable. Log group is KMS-encrypted + retention-bounded below.
475 AccessLogSetting :
476 DestinationArn : !GetAtt ApiAccessLogGroup.Arn
477 Format : '{"requestId":"$context.requestId","ip":"$context.identity.sourceIp","requestTime":"$context.requestTime","httpMethod":"$context.httpMethod","routeKey":"$context.routeKey","status":"$context.status","protocol":"$context.protocol","responseLength":"$context.responseLength"}'
478 MethodSettings :
479 - HttpMethod : '*'
480 ResourcePath : '/*'
481 ThrottlingBurstLimit : 10
482 ThrottlingRateLimit : 5
483 Tags :
484 ManagedBy : marketplace-metering-skill
485
486 # Access log group for the registration API: explicit, KMS-encrypted,
487 # retention-bounded — not an auto-created, unencrypted, never-expiring default.
488 ApiAccessLogGroup :
489 Type : AWS::Logs::LogGroup
490 DeletionPolicy : Retain
491 UpdateReplacePolicy : Retain
492 Properties :
493 LogGroupName : !Sub /aws/apigateway/${StackPrefix}-registration-api/access
494 RetentionInDays : !Ref LogRetentionInDays
495 KmsKeyId : !If [ HasLogsKmsKey , !Ref LogsKmsKeyArn , !Ref 'AWS::NoValue' ]
496
497 # WAF for the unauthenticated registration endpoint (DefaultAuthorizer: NONE — Marketplace
498 # POSTs the token unauthenticated). By default the stack CREATES a baseline REGIONAL WebACL
499 # (rate-based + AWS common-exploit managed rules) and attaches it to the stage, so the public
500 # endpoint is protected out of the box. A seller-supplied WebAclArn overrides the baseline.
501 BaselineWebAcl :
502 Type : AWS::WAFv2::WebACL
503 Condition : UseBaselineWebAcl
504 Properties :
505 Name : !Sub ${StackPrefix}-registration-waf
506 Scope : REGIONAL
507 DefaultAction :
508 Allow : {}
509 VisibilityConfig :
510 SampledRequestsEnabled : true
511 CloudWatchMetricsEnabled : true
512 MetricName : !Sub ${StackPrefix}-registration-waf
513 Rules :
514 - Name : RateLimit
515 Priority : 0
516 Action :
517 Block : {}
518 Statement :
519 RateBasedStatement :
520 Limit : 2000
521 AggregateKeyType : IP
522 VisibilityConfig :
523 SampledRequestsEnabled : true
524 CloudWatchMetricsEnabled : true
525 MetricName : !Sub ${StackPrefix}-registration-waf-ratelimit
526 - Name : CommonRuleSet
527 Priority : 1
528 OverrideAction :
529 None : {}
530 Statement :
531 ManagedRuleGroupStatement :
532 VendorName : AWS
533 Name : AWSManagedRulesCommonRuleSet
534 VisibilityConfig :
535 SampledRequestsEnabled : true
536 CloudWatchMetricsEnabled : true
537 MetricName : !Sub ${StackPrefix}-registration-waf-common
538 Tags :
539 - Key : ManagedBy
540 Value : marketplace-metering-skill
541
542 # Attach the baseline WebACL (created above) when no seller override is provided.
543 RegistrationApiWafAssociationBaseline :
544 Type : AWS::WAFv2::WebACLAssociation
545 Condition : UseBaselineWebAcl
546 # depend on the SAM-generated stage, else the association may run before the
547 # stage exists and WAF returns NotFound (the !Sub only implies a dep on the RestApi).
548 DependsOn : RegistrationApiStage
549 Properties :
550 ResourceArn : !Sub arn:aws:apigateway:${AWS::Region}::/restapis/${RegistrationApi}/stages/${StageName}
551 WebACLArn : !GetAtt BaselineWebAcl.Arn
552
553 # Attach the seller-supplied WebACL instead, when WebAclArn is provided.
554 RegistrationApiWafAssociationProvided :
555 Type : AWS::WAFv2::WebACLAssociation
556 Condition : HasWebAcl
557 DependsOn : RegistrationApiStage
558 Properties :
559 ResourceArn : !Sub arn:aws:apigateway:${AWS::Region}::/restapis/${RegistrationApi}/stages/${StageName}
560 WebACLArn : !Ref WebAclArn
561
562 RegisterFunction :
563 Type : AWS::Serverless::Function
564 Properties :
565 FunctionName : !Sub ${StackPrefix}-register
566 Handler : handlers/register.handler
567 CodeUri : src/
568 MemorySize : 128
569 Environment :
570 Variables :
571 # Registration input allowlist. The Globals block already sets
572 # PRODUCT_CODE / SUBSCRIBERS_TABLE / SUBSCRIBERS_TABLE_REGION.
573 ALLOWED_REGISTRATION_FIELDS : !Ref AllowedRegistrationFields
574 # In-region customer-profile table where buyer PII (registrationData map +
575 # any promoted top-level fields) is stored — NOT the us-east-1 subscribers table.
576 CUSTOMER_PROFILE_TABLE : !Ref CustomerProfileTable
577 # Comma-separated subset of ALLOWED_REGISTRATION_FIELDS the seller promoted to
578 # top-level attributes (for the GSIs defined on the customer-profile table).
579 PROMOTED_PROFILE_FIELDS : !Ref PromotedProfileFields
580 Policies :
581 - Statement :
582 # Buyer PII (registrationData + promoted fields) goes to the IN-REGION
583 # customer-profile table only.
584 - Effect : Allow
585 Action :
586 - dynamodb:PutItem
587 - dynamodb:UpdateItem
588 - dynamodb:Query
589 Resource :
590 - !GetAtt CustomerProfileTable.Arn
591 - !Sub ${CustomerProfileTable.Arn}/index/*
592 - Statement :
593 # us-east-1 subscribers table: PII-FREE. The register Lambda only reads the row
594 # (idempotent registeredRegions check) and appends its Region — no PII write.
595 - Effect : Allow
596 Action :
597 - dynamodb:GetItem
598 - dynamodb:UpdateItem
599 - dynamodb:Query
600 Resource :
601 - !Sub arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/${SubscribersTableName}
602 # Query the customerAWSAccountId GSI to find the subscriber row
603 # (Query, not a full-table Scan). No dynamodb:Scan is granted.
604 - !Sub arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/${SubscribersTableName}/index/customerAWSAccountId-index
605 - Statement :
606 - Effect : Allow
607 Action : aws-marketplace:ResolveCustomer
608 # Resource '*' required — ResolveCustomer does not support resource-level ARNs.
609 Resource : '*'
610 Tags :
611 ManagedBy : marketplace-metering-skill
612 Events :
613 PostRegistration :
614 Type : Api
615 Properties :
616 RestApiId : !Ref RegistrationApi
617 Path : /register
618 Method : POST
619
620 RegisterLogGroup :
621 Type : AWS::Logs::LogGroup
622 DeletionPolicy : Retain
623 UpdateReplacePolicy : Retain
624 Properties :
625 LogGroupName : !Sub /aws/lambda/${StackPrefix}-register
626 RetentionInDays : !Ref LogRetentionInDays
627 KmsKeyId : !If [ HasLogsKmsKey , !Ref LogsKmsKeyArn , !Ref 'AWS::NoValue' ]
628
629 # ── Decoupled metering pipeline ────────────────────────────────────────
630 # Replaces the single hourly meter Lambda. Discoverer (30 invocations via 6 rules x 5
631 # targets) -> work queue -> Aggregator (parallel) -> aggregated_usage + cleanup queue;
632 # Cleanup clears raw-row meteringPending; Submitter (reserved=1, scheduled) reads
633 # aggregated_usage and calls BatchMeterUsage.
634
635 # Discoverer: one function, ReservedConcurrentExecutions=30. Each invocation owns one
636 # slice via its constant EventBridge target Input ({mode:meter,hourOffset} or
637 # {mode:ageout,shard}). Reads the metering_pending GSI and enqueues one work message per
638 # pending group; age-out invocations expire >24h rows directly.
639 DiscovererFunction :
640 Type : AWS::Serverless::Function
641 Condition : IsFullPipeline
642 Properties :
643 FunctionName : !Sub ${StackPrefix}-discoverer
644 Handler : handlers/discoverer.handler
645 CodeUri : src/
646 MemorySize : 256
647 Timeout : 300
648 ReservedConcurrentExecutions : 30
649 Environment :
650 Variables :
651 WORK_QUEUE_URL : !Ref MeteringWorkQueue
652 DEPROVISION_WORK_QUEUE_URL : !Ref DeprovisionWorkQueue
653 METERING_LOCK_HOURS : !Ref MeteringLockHours
654 USAGE_TABLE : !Ref UsageTable
655 METRIC_NAMESPACE : AwsMarketplace/Metering
656 Policies :
657 - Statement :
658 - Effect : Allow
659 Action :
660 - dynamodb:Query
661 Resource :
662 # Discovery queries the metering_pending GSI (and the base table).
663 - !GetAtt UsageTable.Arn
664 - !Sub ${UsageTable.Arn}/index/metering_pending
665 - Effect : Allow
666 Action :
667 - dynamodb:UpdateItem # age-out terminal update on raw rows
668 Resource :
669 # Writes authorize against the table ARN only, never an index ARN.
670 - !GetAtt UsageTable.Arn
671 - Effect : Allow
672 Action :
673 - dynamodb:Query
674 Resource :
675 # flush-deprovisioning sweep queries the sparse deprovisioning index on the
676 # (us-east-1) subscribers table to find licenses in their ~1h flush window.
677 # Query, not Scan.
678 - !Sub arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/${SubscribersTableName}/index/deprovisioning-pending-index
679 - Statement :
680 - Effect : Allow
681 Action : sqs:SendMessage
682 Resource :
683 - !GetAtt MeteringWorkQueue.Arn
684 - !GetAtt DeprovisionWorkQueue.Arn
685 Tags :
686 ManagedBy : marketplace-metering-skill
687
688 DiscovererLogGroup :
689 Type : AWS::Logs::LogGroup
690 Condition : IsFullPipeline
691 DeletionPolicy : Retain
692 UpdateReplacePolicy : Retain
693 Properties :
694 LogGroupName : !Sub /aws/lambda/${StackPrefix}-discoverer
695 RetentionInDays : !Ref LogRetentionInDays
696 KmsKeyId : !If [ HasLogsKmsKey , !Ref LogsKmsKeyArn , !Ref 'AWS::NoValue' ]
697
698 # Permission for EventBridge rules to invoke the discoverer.
699 DiscovererInvokePermission :
700 Type : AWS::Lambda::Permission
701 Condition : IsFullPipeline
702 Properties :
703 FunctionName : !Ref DiscovererFunction
704 Action : lambda:InvokeFunction
705 Principal : events.amazonaws.com
706 SourceArn : !Sub arn:aws:events:${AWS::Region}:${AWS::AccountId}:rule/${StackPrefix}-meter-dispatch-*
707
708 # Aggregator: SQS-triggered (work queue). Reads one group, folds + validates, rejects
709 # client-side OR conditional-PutItem into aggregated_usage, then enqueues cleanup.
710 AggregatorFunction :
711 Type : AWS::Serverless::Function
712 Condition : IsFullPipeline
713 Properties :
714 FunctionName : !Sub ${StackPrefix}-aggregator
715 Handler : handlers/aggregator.handler
716 CodeUri : src/
717 MemorySize : 512
718 Timeout : 60
719 Environment :
720 Variables :
721 CLEANUP_QUEUE_URL : !Ref MeteringCleanupQueue
722 USAGE_TABLE : !Ref UsageTable
723 METRIC_NAMESPACE : AwsMarketplace/Metering
724 Policies :
725 - Statement :
726 - Effect : Allow
727 Action :
728 - dynamodb:Query
729 - dynamodb:UpdateItem # client-side reject writes terminal status on raw rows
730 Resource :
731 # Base table only: the aggregator reads a group via a base-table
732 # begins_with Query (never the metering_pending GSI — that's the
733 # discoverer's), and UpdateItem authorizes against the table ARN, not an
734 # index ARN.
735 - !GetAtt UsageTable.Arn
736 - Statement :
737 - Effect : Allow
738 Action :
739 - dynamodb:PutItem # conditional put (commit point)
740 Resource :
741 - !GetAtt AggregatedUsageTable.Arn
742 - Statement :
743 - Effect : Allow
744 Action : sqs:SendMessage
745 Resource : !GetAtt MeteringCleanupQueue.Arn
746 Tags :
747 ManagedBy : marketplace-metering-skill
748 Events :
749 WorkQueue :
750 Type : SQS
751 Properties :
752 Queue : !GetAtt MeteringWorkQueue.Arn
753 BatchSize : 10
754 FunctionResponseTypes :
755 - ReportBatchItemFailures
756 ScalingConfig :
757 # Option A ( decision 1): cap fan-out so downstream stays within limits.
758 MaximumConcurrency : 50
759 DeprovisionWorkQueue :
760 Type : SQS
761 Properties :
762 # Dedicated deprovisioning flush queue — SAME aggregator function, isolated ESM so
763 # a deprovisioning backlog drains independently of the regular work backlog.
764 Queue : !GetAtt DeprovisionWorkQueue.Arn
765 BatchSize : 10
766 FunctionResponseTypes :
767 - ReportBatchItemFailures
768 ScalingConfig :
769 MaximumConcurrency : 50
770
771 AggregatorLogGroup :
772 Type : AWS::Logs::LogGroup
773 Condition : IsFullPipeline
774 DeletionPolicy : Retain
775 UpdateReplacePolicy : Retain
776 Properties :
777 LogGroupName : !Sub /aws/lambda/${StackPrefix}-aggregator
778 RetentionInDays : !Ref LogRetentionInDays
779 KmsKeyId : !If [ HasLogsKmsKey , !Ref LogsKmsKeyArn , !Ref 'AWS::NoValue' ]
780
781 # Cleanup: SQS-triggered (cleanup queue). Finalizes raw usage rows: clears
782 # meteringPending and stamps meteringStatus=Aggregated + "Aggregated total quantity <sum>"
783 # (per-key UpdateItem; DynamoDB BatchWriteItem cannot partially update an attribute).
784 CleanupFunction :
785 Type : AWS::Serverless::Function
786 Condition : IsFullPipeline
787 Properties :
788 FunctionName : !Sub ${StackPrefix}-cleanup
789 Handler : handlers/cleanup.handler
790 CodeUri : src/
791 MemorySize : 256
792 Timeout : 120
793 Environment :
794 Variables :
795 # Bounded thread-pool size for parallel per-key UpdateItem REMOVE; guards
796 # the ~1000 WCU/sec per-partition limit (all rows share the licenseArn partition).
797 CLEANUP_MAX_WORKERS : '10'
798 USAGE_TABLE : !Ref UsageTable
799 METRIC_NAMESPACE : AwsMarketplace/Metering
800 Policies :
801 - Statement :
802 - Effect : Allow
803 Action :
804 - dynamodb:UpdateItem
805 Resource :
806 - !GetAtt UsageTable.Arn
807 Tags :
808 ManagedBy : marketplace-metering-skill
809 Events :
810 CleanupQueue :
811 Type : SQS
812 Properties :
813 Queue : !GetAtt MeteringCleanupQueue.Arn
814 BatchSize : 10
815 # Drain as fast as the DynamoDB hot-partition limit allows (the real bound, NOT
816 # SQS): pick messages up immediately (no batching wait) and raise the concurrency
817 # cap. The global instantaneous writer count is still MaximumConcurrency ×
818 # CLEANUP_MAX_WORKERS; keep it under the ~1000 WCU/s per-licenseArn-partition
819 # limit. 10 × 10 = 100 concurrent surgical UpdateItems, which drains the queue
820 # quickly while a per-invocation backoff absorbs any transient throttling.
821 MaximumBatchingWindowInSeconds : 0
822 FunctionResponseTypes :
823 - ReportBatchItemFailures
824 ScalingConfig :
825 # Bound GLOBAL cleanup concurrency, not just the per-invocation thread pool.
826 # All rows of a group share the licenseArn partition (~1000 WCU/s); a hot
827 # license fans out into up to ~36 cleanup messages, so without this cap many
828 # invocations (each with CLEANUP_MAX_WORKERS threads) could hammer one
829 # partition. Global writers ≈ MaximumConcurrency × CLEANUP_MAX_WORKERS.
830 MaximumConcurrency : 10
831
832 CleanupLogGroup :
833 Type : AWS::Logs::LogGroup
834 Condition : IsFullPipeline
835 DeletionPolicy : Retain
836 UpdateReplacePolicy : Retain
837 Properties :
838 LogGroupName : !Sub /aws/lambda/${StackPrefix}-cleanup
839 RetentionInDays : !Ref LogRetentionInDays
840 KmsKeyId : !If [ HasLogsKmsKey , !Ref LogsKmsKeyArn , !Ref 'AWS::NoValue' ]
841
842 # Submitter: EventBridge-scheduled, ReservedConcurrentExecutions=1 (single serial
843 # submitter — safe vs. the BatchMeterUsage rate limit). Reads aggregated_usage pending,
844 # coalesces <=25 -> BatchMeterUsage, writes back to aggregated_usage.
845 SubmitterFunction :
846 Type : AWS::Serverless::Function
847 Properties :
848 FunctionName : !Sub ${StackPrefix}-submitter
849 Handler : handlers/submitter.handler
850 CodeUri : src/
851 MemorySize : 256
852 Timeout : 300
853 ReservedConcurrentExecutions : 1
854 Environment :
855 Variables :
856 # >0 => set a `ttl` epoch on finalized Success rows (AggregatedUsageTableTtlDays);
857 # 0 => never set ttl (table has no TTL spec, so this is a no-op anyway).
858 AGGREGATED_USAGE_TTL_DAYS : !Ref AggregatedUsageTableTtlDays
859 METRIC_NAMESPACE : AwsMarketplace/Metering
860 # live => real BatchMeterUsage; dry-run => full pipeline but NO real submit
861 # (writes DryRunSubmitted). Non-prod sandbox stages set dry-run.
862 METERING_MODE : !Ref MeteringMode
863 Policies :
864 - Statement :
865 - Effect : Allow
866 Action :
867 # Query the sparse deprovisioning index to prioritize deprovisioning-license
868 # records first (submitter no longer finalizes subscribers — that moved to the
869 # events-stack deprovision-cleanup Lambda). Query, not Scan; index only.
870 - dynamodb:Query
871 Resource :
872 - !Sub arn:aws:dynamodb:us-east-1:${AWS::AccountId}:table/${SubscribersTableName}/index/deprovisioning-pending-index
873 - Statement :
874 - Effect : Allow
875 Action :
876 - dynamodb:Query
877 - dynamodb:UpdateItem
878 Resource :
879 - !GetAtt AggregatedUsageTable.Arn
880 - !Sub ${AggregatedUsageTable.Arn}/index/metering_pending
881 - Statement :
882 - Effect : Allow
883 Action : aws-marketplace:BatchMeterUsage
884 # Resource '*' required — BatchMeterUsage does not support resource-level ARNs.
885 Resource : '*'
886 Tags :
887 ManagedBy : marketplace-metering-skill
888 # NOTE: no SAM Schedule event here — the submitter is a target of the shared
889 # SubmitDispatchRule below (same rate(5 minutes) rule that also fires the expiry
890 # Lambda), so the two run independently off ONE rule.
891
892 SubmitterLogGroup :
893 Type : AWS::Logs::LogGroup
894 DeletionPolicy : Retain
895 UpdateReplacePolicy : Retain
896 Properties :
897 LogGroupName : !Sub /aws/lambda/${StackPrefix}-submitter
898 RetentionInDays : !Ref LogRetentionInDays
899 KmsKeyId : !If [ HasLogsKmsKey , !Ref LogsKmsKeyArn , !Ref 'AWS::NoValue' ]
900
901 # Submission-expiry: scheduled (shared rule below), reserved=1. Sweeps aggregated_usage and
902 # terminally expires pending records past the 24h submittable window (and, once the
903 # month-end grace closes at 06:00 UTC on the 1st, previous-month records) — so a record
904 # the submitter never drained is never left pending forever, and the loss is alarmed via
905 # the UsageSubmissionExpired metric (distinct from the raw table's UsageAggregationExpired
906 # age-out). aggregated_usage only; the raw table's age-out is the discoverer's ageout shards.
907 SubmissionExpiryFunction :
908 Type : AWS::Serverless::Function
909 Properties :
910 FunctionName : !Sub ${StackPrefix}-submission-expiry
911 Handler : handlers/expiry.handler
912 CodeUri : src/
913 MemorySize : 256
914 Timeout : 300
915 ReservedConcurrentExecutions : 1
916 Environment :
917 Variables :
918 METRIC_NAMESPACE : AwsMarketplace/Metering
919 Policies :
920 - Statement :
921 - Effect : Allow
922 Action :
923 - dynamodb:Query
924 Resource :
925 - !GetAtt AggregatedUsageTable.Arn
926 - !Sub ${AggregatedUsageTable.Arn}/index/metering_pending
927 - Effect : Allow
928 Action :
929 - dynamodb:UpdateItem # terminal SubmissionExpired write; table ARN only
930 Resource :
931 - !GetAtt AggregatedUsageTable.Arn
932 Tags :
933 ManagedBy : marketplace-metering-skill
934
935 SubmissionExpiryLogGroup :
936 Type : AWS::Logs::LogGroup
937 DeletionPolicy : Retain
938 UpdateReplacePolicy : Retain
939 Properties :
940 LogGroupName : !Sub /aws/lambda/${StackPrefix}-submission-expiry
941 RetentionInDays : !Ref LogRetentionInDays
942 KmsKeyId : !If [ HasLogsKmsKey , !Ref LogsKmsKeyArn , !Ref 'AWS::NoValue' ]
943
944 # One rate(5 minutes) rule fires BOTH the submitter and the submission-expiry Lambda
945 # (independent targets, each reserved=1). Submitter drains the now-23h..now-1h window
946 # oldest-first; expiry sweeps aggregated_usage for records past the window.
947 SubmitDispatchRule :
948 Type : AWS::Events::Rule
949 Properties :
950 Name : !Sub ${StackPrefix}-submit-dispatch
951 ScheduleExpression : rate(5 minutes)
952 State : ENABLED
953 Targets :
954 - Id : submitter
955 Arn : !GetAtt SubmitterFunction.Arn
956 - Id : expiry
957 Arn : !GetAtt SubmissionExpiryFunction.Arn
958
959 SubmitterInvokePermission :
960 Type : AWS::Lambda::Permission
961 Properties :
962 FunctionName : !Ref SubmitterFunction
963 Action : lambda:InvokeFunction
964 Principal : events.amazonaws.com
965 SourceArn : !GetAtt SubmitDispatchRule.Arn
966
967 SubmissionExpiryInvokePermission :
968 Type : AWS::Lambda::Permission
969 Properties :
970 FunctionName : !Ref SubmissionExpiryFunction
971 Action : lambda:InvokeFunction
972 Principal : events.amazonaws.com
973 SourceArn : !GetAtt SubmitDispatchRule.Arn
974
975 # Expedited deprovisioning flush: a rate(5m) rule invokes the EXISTING discoverer
976 # in flush-deprovisioning mode. It queries the sparse deprovisioning-pending-index and
977 # enqueues each deprovisioning license's now-23h..now-1h pending groups to the work queue,
978 # BYPASSING MeteringLockHours so the ~1h flush window is met. This is ADDITIVE and
979 # independent of the ordinary hourly meter/ageout rules (which are unchanged).
980 FlushDeprovisioningRule :
981 Type : AWS::Events::Rule
982 Condition : IsFullPipeline
983 Properties :
984 Name : !Sub ${StackPrefix}-flush-deprovisioning
985 ScheduleExpression : rate(5 minutes)
986 State : ENABLED
987 Targets :
988 - Id : discoverer-flush-deprovisioning
989 Arn : !GetAtt DiscovererFunction.Arn
990 Input : '{"mode":"flush-deprovisioning"}'
991
992 FlushDeprovisioningInvokePermission :
993 Type : AWS::Lambda::Permission
994 Condition : IsFullPipeline
995 Properties :
996 FunctionName : !Ref DiscovererFunction
997 Action : lambda:InvokeFunction
998 Principal : events.amazonaws.com
999 SourceArn : !GetAtt FlushDeprovisioningRule.Arn
1000
1001 # ── Discoverer schedule: 6 rules x 5 constant-Input targets = 30 invocations ──────
1002 # 23 meter-hour offsets + 7 age-out shards. Slice identity is baked into each target's
1003 # Input (single source of truth); the discoverer derives the hour as floor(now,h)-offset,
1004 # so buckets are disjoint and gap-free. EventBridge caps a rule at 5 targets, so
1005 # 30 invocations require 6 rules.
1006 MeterDispatchRule1 :
1007 Type : AWS::Events::Rule
1008 Condition : IsFullPipeline
1009 Properties :
1010 Name : !Sub ${StackPrefix}-meter-dispatch-1
1011 ScheduleExpression : rate(15 minutes)
1012 State : ENABLED
1013 Targets :
1014 - Id : h1
1015 Arn : !GetAtt DiscovererFunction.Arn
1016 Input : '{"mode":"meter","hourOffset":1}'
1017 - Id : h2
1018 Arn : !GetAtt DiscovererFunction.Arn
1019 Input : '{"mode":"meter","hourOffset":2}'
1020 - Id : h3
1021 Arn : !GetAtt DiscovererFunction.Arn
1022 Input : '{"mode":"meter","hourOffset":3}'
1023 - Id : h4
1024 Arn : !GetAtt DiscovererFunction.Arn
1025 Input : '{"mode":"meter","hourOffset":4}'
1026 - Id : h5
1027 Arn : !GetAtt DiscovererFunction.Arn
1028 Input : '{"mode":"meter","hourOffset":5}'
1029
1030 MeterDispatchRule2 :
1031 Type : AWS::Events::Rule
1032 Condition : IsFullPipeline
1033 Properties :
1034 Name : !Sub ${StackPrefix}-meter-dispatch-2
1035 ScheduleExpression : rate(15 minutes)
1036 State : ENABLED
1037 Targets :
1038 - Id : h6
1039 Arn : !GetAtt DiscovererFunction.Arn
1040 Input : '{"mode":"meter","hourOffset":6}'
1041 - Id : h7
1042 Arn : !GetAtt DiscovererFunction.Arn
1043 Input : '{"mode":"meter","hourOffset":7}'
1044 - Id : h8
1045 Arn : !GetAtt DiscovererFunction.Arn
1046 Input : '{"mode":"meter","hourOffset":8}'
1047 - Id : h9
1048 Arn : !GetAtt DiscovererFunction.Arn
1049 Input : '{"mode":"meter","hourOffset":9}'
1050 - Id : h10
1051 Arn : !GetAtt DiscovererFunction.Arn
1052 Input : '{"mode":"meter","hourOffset":10}'
1053
1054 MeterDispatchRule3 :
1055 Type : AWS::Events::Rule
1056 Condition : IsFullPipeline
1057 Properties :
1058 Name : !Sub ${StackPrefix}-meter-dispatch-3
1059 ScheduleExpression : rate(15 minutes)
1060 State : ENABLED
1061 Targets :
1062 - Id : h11
1063 Arn : !GetAtt DiscovererFunction.Arn
1064 Input : '{"mode":"meter","hourOffset":11}'
1065 - Id : h12
1066 Arn : !GetAtt DiscovererFunction.Arn
1067 Input : '{"mode":"meter","hourOffset":12}'
1068 - Id : h13
1069 Arn : !GetAtt DiscovererFunction.Arn
1070 Input : '{"mode":"meter","hourOffset":13}'
1071 - Id : h14
1072 Arn : !GetAtt DiscovererFunction.Arn
1073 Input : '{"mode":"meter","hourOffset":14}'
1074 - Id : h15
1075 Arn : !GetAtt DiscovererFunction.Arn
1076 Input : '{"mode":"meter","hourOffset":15}'
1077
1078 MeterDispatchRule4 :
1079 Type : AWS::Events::Rule
1080 Condition : IsFullPipeline
1081 Properties :
1082 Name : !Sub ${StackPrefix}-meter-dispatch-4
1083 ScheduleExpression : rate(15 minutes)
1084 State : ENABLED
1085 Targets :
1086 - Id : h16
1087 Arn : !GetAtt DiscovererFunction.Arn
1088 Input : '{"mode":"meter","hourOffset":16}'
1089 - Id : h17
1090 Arn : !GetAtt DiscovererFunction.Arn
1091 Input : '{"mode":"meter","hourOffset":17}'
1092 - Id : h18
1093 Arn : !GetAtt DiscovererFunction.Arn
1094 Input : '{"mode":"meter","hourOffset":18}'
1095 - Id : h19
1096 Arn : !GetAtt DiscovererFunction.Arn
1097 Input : '{"mode":"meter","hourOffset":19}'
1098 - Id : h20
1099 Arn : !GetAtt DiscovererFunction.Arn
1100 Input : '{"mode":"meter","hourOffset":20}'
1101
1102 MeterDispatchRule5 :
1103 Type : AWS::Events::Rule
1104 Condition : IsFullPipeline
1105 Properties :
1106 Name : !Sub ${StackPrefix}-meter-dispatch-5
1107 ScheduleExpression : rate(15 minutes)
1108 State : ENABLED
1109 Targets :
1110 - Id : h21
1111 Arn : !GetAtt DiscovererFunction.Arn
1112 Input : '{"mode":"meter","hourOffset":21}'
1113 - Id : h22
1114 Arn : !GetAtt DiscovererFunction.Arn
1115 Input : '{"mode":"meter","hourOffset":22}'
1116 - Id : h23
1117 Arn : !GetAtt DiscovererFunction.Arn
1118 Input : '{"mode":"meter","hourOffset":23}'
1119 - Id : a0
1120 Arn : !GetAtt DiscovererFunction.Arn
1121 Input : '{"mode":"ageout","shard":0}'
1122 - Id : a1
1123 Arn : !GetAtt DiscovererFunction.Arn
1124 Input : '{"mode":"ageout","shard":1}'
1125
1126 MeterDispatchRule6 :
1127 Type : AWS::Events::Rule
1128 Condition : IsFullPipeline
1129 Properties :
1130 Name : !Sub ${StackPrefix}-meter-dispatch-6
1131 ScheduleExpression : rate(15 minutes)
1132 State : ENABLED
1133 Targets :
1134 - Id : a2
1135 Arn : !GetAtt DiscovererFunction.Arn
1136 Input : '{"mode":"ageout","shard":2}'
1137 - Id : a3
1138 Arn : !GetAtt DiscovererFunction.Arn
1139 Input : '{"mode":"ageout","shard":3}'
1140 - Id : a4
1141 Arn : !GetAtt DiscovererFunction.Arn
1142 Input : '{"mode":"ageout","shard":4}'
1143 - Id : a5
1144 Arn : !GetAtt DiscovererFunction.Arn
1145 Input : '{"mode":"ageout","shard":5}'
1146 - Id : a6
1147 Arn : !GetAtt DiscovererFunction.Arn
1148 Input : '{"mode":"ageout","shard":6}'
1149
1150 # ── Monitoring & Alarms (created in the SELLER's account) ─────────────────────────
1151 # These alarms cover the minimum revenue-loss signals. Alarms are ALWAYS created; each
1152 # alarm's AlarmActions wire to an OPTIONAL seller-supplied in-region SNS topic
1153 # (AlertsTopicArn) so observability does not depend on pre-supplying a topic.
1154 #
1155 # Handling alarms and acting on failures is the SELLER's responsibility — AWS
1156 # Marketplace and this skill do NOT track or act on client-side (seller-account)
1157 # errors. TODO(seller): supply AlertsTopicArn (an in-region SNS topic) and set your own
1158 # thresholds/actions; neither the deployer role nor any Lambda publishes to the topic
1159 # (CloudWatch fires the action). Business-status metrics are emitted via EMF from the
1160 # meter Lambda logs (below) to avoid extra runtime IAM.
1161
1162 # 1) Pipeline Lambda Errors — the discoverer, submitter, and expiry RAISE on failure, so
1163 # their Lambda Errors metric increments and this alarm fires. NOTE: the aggregator and
1164 # cleanup use ReportBatchItemFailures, so a per-message failure does NOT increment their
1165 # Errors metric (only a top-level config error would) — their real failure signal is the
1166 # work/cleanup DLQ-depth + work-queue-backlog alarms below. This metric-math alarm sums
1167 # the raising functions' Errors.
1168 MeteringErrorAlarm :
1169 Type : AWS::CloudWatch::Alarm
1170 Condition : IsFullPipeline
1171 Properties :
1172 AlarmName : !Sub ${StackPrefix}-meter-errors
1173 AlarmDescription : Metering pipeline Lambda errors (discoverer/submitter/expiry raise; aggregator/cleanup via DLQ alarms) - potential revenue loss
1174 Metrics :
1175 - Id : e1
1176 MetricStat :
1177 Metric :
1178 Namespace : AWS/Lambda
1179 MetricName : Errors
1180 Dimensions :
1181 - Name : FunctionName
1182 Value : !Ref DiscovererFunction
1183 Period : 3600
1184 Stat : Sum
1185 ReturnData : false
1186 - Id : e2
1187 MetricStat :
1188 Metric :
1189 Namespace : AWS/Lambda
1190 MetricName : Errors
1191 Dimensions :
1192 - Name : FunctionName
1193 Value : !Ref AggregatorFunction
1194 Period : 3600
1195 Stat : Sum
1196 ReturnData : false
1197 - Id : e3
1198 MetricStat :
1199 Metric :
1200 Namespace : AWS/Lambda
1201 MetricName : Errors
1202 Dimensions :
1203 - Name : FunctionName
1204 Value : !Ref SubmitterFunction
1205 Period : 3600
1206 Stat : Sum
1207 ReturnData : false
1208 - Id : e4
1209 MetricStat :
1210 Metric :
1211 Namespace : AWS/Lambda
1212 MetricName : Errors
1213 Dimensions :
1214 - Name : FunctionName
1215 Value : !Ref SubmissionExpiryFunction
1216 Period : 3600
1217 Stat : Sum
1218 ReturnData : false
1219 - Id : total
1220 Expression : e1 + e2 + e3 + e4
1221 Label : PipelineErrors
1222 ReturnData : true
1223 EvaluationPeriods : 1
1224 Threshold : 1
1225 ComparisonOperator : GreaterThanOrEqualToThreshold
1226 TreatMissingData : notBreaching
1227 AlarmActions :
1228 - !If [ HasAlertsTopic , !Ref AlertsTopicArn , !Ref 'AWS::NoValue' ]
1229
1230 # Direct-submit mode: only the submitter + submission-expiry exist, so the Errors alarm
1231 # covers just those two.
1232 MeteringErrorAlarmDirect :
1233 Type : AWS::CloudWatch::Alarm
1234 Condition : IsDirectSubmit
1235 Properties :
1236 AlarmName : !Sub ${StackPrefix}-meter-errors
1237 AlarmDescription : Metering Lambda errors (submitter/submission-expiry) - potential revenue loss
1238 Metrics :
1239 - Id : e3
1240 MetricStat :
1241 Metric :
1242 Namespace : AWS/Lambda
1243 MetricName : Errors
1244 Dimensions :
1245 - Name : FunctionName
1246 Value : !Ref SubmitterFunction
1247 Period : 3600
1248 Stat : Sum
1249 ReturnData : false
1250 - Id : e4
1251 MetricStat :
1252 Metric :
1253 Namespace : AWS/Lambda
1254 MetricName : Errors
1255 Dimensions :
1256 - Name : FunctionName
1257 Value : !Ref SubmissionExpiryFunction
1258 Period : 3600
1259 Stat : Sum
1260 ReturnData : false
1261 - Id : total
1262 Expression : e3 + e4
1263 Label : PipelineErrors
1264 ReturnData : true
1265 EvaluationPeriods : 1
1266 Threshold : 1
1267 ComparisonOperator : GreaterThanOrEqualToThreshold
1268 TreatMissingData : notBreaching
1269 AlarmActions :
1270 - !If [ HasAlertsTopic , !Ref AlertsTopicArn , !Ref 'AWS::NoValue' ]
1271
1272 # 2) Submitter Throttles — the submitter is reserved=1 (safe vs. BatchMeterUsage rate
1273 # limit); throttling here means aggregated usage is not being submitted promptly.
1274 MeteringThrottleAlarm :
1275 Type : AWS::CloudWatch::Alarm
1276 Properties :
1277 AlarmName : !Sub ${StackPrefix}-meter-throttles
1278 AlarmDescription : Submitter throttled - aggregated usage may not be submitted in time
1279 Namespace : AWS/Lambda
1280 MetricName : Throttles
1281 Dimensions :
1282 - Name : FunctionName
1283 Value : !Ref SubmitterFunction
1284 Statistic : Sum
1285 Period : 3600
1286 EvaluationPeriods : 1
1287 Threshold : 1
1288 ComparisonOperator : GreaterThanOrEqualToThreshold
1289 TreatMissingData : notBreaching
1290 AlarmActions :
1291 - !If [ HasAlertsTopic , !Ref AlertsTopicArn , !Ref 'AWS::NoValue' ]
1292
1293 # 3) "Submitter did not run" — the submitter runs on a schedule; if it stops being
1294 # invoked (a silent scheduler failure emits no error) aggregated usage silently stops
1295 # being submitted. Missing data is treated as BREACHING so a total stall is caught.
1296 MeteringDidNotRunAlarm :
1297 Type : AWS::CloudWatch::Alarm
1298 Properties :
1299 AlarmName : !Sub ${StackPrefix}-meter-not-run
1300 AlarmDescription : Submitter has not run recently - aggregated usage is not being submitted
1301 Namespace : AWS/Lambda
1302 MetricName : Invocations
1303 Dimensions :
1304 - Name : FunctionName
1305 Value : !Ref SubmitterFunction
1306 Statistic : Sum
1307 Period : 3600
1308 EvaluationPeriods : 1
1309 Threshold : 1
1310 ComparisonOperator : LessThanThreshold
1311 TreatMissingData : breaching
1312 AlarmActions :
1313 - !If [ HasAlertsTopic , !Ref AlertsTopicArn , !Ref 'AWS::NoValue' ]
1314
1315 # 3-disc) "Discoverer did not run" — CRITICAL: the 6 EventBridge dispatch rules drive the
1316 # discoverer, which is the ONLY thing that enqueues work AND the ONLY thing that ages
1317 # out >24h usage. If the rules stop firing (disabled, PutTargets regression, permission
1318 # removed) NOTHING is enqueued and NOTHING ages out — yet the submitter keeps running
1319 # on an empty aggregated_usage, so every other alarm stays green. Without this alarm a
1320 # total metering stall is SILENT. 30 invocations/hour are expected; alarm if fewer than
1321 # 1 in a 2h window. Missing data is BREACHING so a full stop is caught.
1322 DiscovererDidNotRunAlarm :
1323 Type : AWS::CloudWatch::Alarm
1324 Condition : IsFullPipeline
1325 Properties :
1326 AlarmName : !Sub ${StackPrefix}-discoverer-not-run
1327 AlarmDescription : Discoverer has not run recently - usage is neither being metered nor aged out (silent stall)
1328 Namespace : AWS/Lambda
1329 MetricName : Invocations
1330 Dimensions :
1331 - Name : FunctionName
1332 Value : !Ref DiscovererFunction
1333 Statistic : Sum
1334 Period : 7200
1335 EvaluationPeriods : 1
1336 Threshold : 1
1337 ComparisonOperator : LessThanThreshold
1338 TreatMissingData : breaching
1339 AlarmActions :
1340 - !If [ HasAlertsTopic , !Ref AlertsTopicArn , !Ref 'AWS::NoValue' ]
1341
1342 # 3a) Work DLQ depth — a message that fails the aggregator maxReceiveCount times lands
1343 # here; it represents a group that could not be aggregated (investigate + redrive).
1344 WorkDLQDepthAlarm :
1345 Type : AWS::CloudWatch::Alarm
1346 Condition : IsFullPipeline
1347 Properties :
1348 AlarmName : !Sub ${StackPrefix}-work-dlq-depth
1349 AlarmDescription : Metering work DLQ has messages - groups failed aggregation
1350 Namespace : AWS/SQS
1351 MetricName : ApproximateNumberOfMessagesVisible
1352 Dimensions :
1353 - Name : QueueName
1354 Value : !GetAtt MeteringWorkDLQ.QueueName
1355 Statistic : Maximum
1356 Period : 3600
1357 EvaluationPeriods : 1
1358 Threshold : 1
1359 ComparisonOperator : GreaterThanOrEqualToThreshold
1360 TreatMissingData : notBreaching
1361 AlarmActions :
1362 - !If [ HasAlertsTopic , !Ref AlertsTopicArn , !Ref 'AWS::NoValue' ]
1363
1364 # 3a-ii) Deprovision-work DLQ depth — a deprovisioning-flush group that failed aggregation
1365 # maxReceiveCount times. Higher urgency than the regular work DLQ: these are licenses in
1366 # their ~1h flush window, so a stuck message risks missing the window (lost revenue).
1367 DeprovisionWorkDLQDepthAlarm :
1368 Type : AWS::CloudWatch::Alarm
1369 Condition : IsFullPipeline
1370 Properties :
1371 AlarmName : !Sub ${StackPrefix}-deprovision-work-dlq-depth
1372 AlarmDescription : Deprovision-work DLQ has messages - a deprovisioning flush failed aggregation (risks missing the ~1h window)
1373 Namespace : AWS/SQS
1374 MetricName : ApproximateNumberOfMessagesVisible
1375 Dimensions :
1376 - Name : QueueName
1377 Value : !GetAtt DeprovisionWorkDLQ.QueueName
1378 Statistic : Maximum
1379 Period : 3600
1380 EvaluationPeriods : 1
1381 Threshold : 1
1382 ComparisonOperator : GreaterThanOrEqualToThreshold
1383 TreatMissingData : notBreaching
1384 AlarmActions :
1385 - !If [ HasAlertsTopic , !Ref AlertsTopicArn , !Ref 'AWS::NoValue' ]
1386
1387 # 3b) Cleanup DLQ depth — a cleanup message that fails maxReceiveCount times lands here;
1388 # raw-row meteringPending was not cleared (rows re-discovered next hour, but the
1389 # aggregator's conditional put no-ops, so this is a health signal, not revenue loss).
1390 CleanupDLQDepthAlarm :
1391 Type : AWS::CloudWatch::Alarm
1392 Condition : IsFullPipeline
1393 Properties :
1394 AlarmName : !Sub ${StackPrefix}-cleanup-dlq-depth
1395 AlarmDescription : Metering cleanup DLQ has messages - raw-row meteringPending not cleared
1396 Namespace : AWS/SQS
1397 MetricName : ApproximateNumberOfMessagesVisible
1398 Dimensions :
1399 - Name : QueueName
1400 Value : !GetAtt MeteringCleanupDLQ.QueueName
1401 Statistic : Maximum
1402 Period : 3600
1403 EvaluationPeriods : 1
1404 Threshold : 1
1405 ComparisonOperator : GreaterThanOrEqualToThreshold
1406 TreatMissingData : notBreaching
1407 AlarmActions :
1408 - !If [ HasAlertsTopic , !Ref AlertsTopicArn , !Ref 'AWS::NoValue' ]
1409
1410 # 3c) Work queue oldest-message age — a STUCK aggregator (event source mapping disabled,
1411 # account-level concurrency starvation, or persistent aggregator throttling) leaves
1412 # messages sitting in the WORK queue (not the DLQ) and aging. Waiting for them to hit
1413 # maxReceiveCount and DLQ would delay detection; this catches the backlog directly.
1414 # Threshold 3h (10800s) tolerates the MeteringLockHours delay + normal drain.
1415 WorkQueueBacklogAlarm :
1416 Type : AWS::CloudWatch::Alarm
1417 Condition : IsFullPipeline
1418 Properties :
1419 AlarmName : !Sub ${StackPrefix}-work-queue-backlog
1420 AlarmDescription : Work queue messages aging - aggregator may be stuck (usage not being aggregated)
1421 Namespace : AWS/SQS
1422 MetricName : ApproximateAgeOfOldestMessage
1423 Dimensions :
1424 - Name : QueueName
1425 Value : !GetAtt MeteringWorkQueue.QueueName
1426 Statistic : Maximum
1427 Period : 3600
1428 EvaluationPeriods : 1
1429 Threshold : 10800
1430 ComparisonOperator : GreaterThanThreshold
1431 TreatMissingData : notBreaching
1432 AlarmActions :
1433 - !If [ HasAlertsTopic , !Ref AlertsTopicArn , !Ref 'AWS::NoValue' ]
1434
1435 # Deprovision-work queue oldest-message-age. Higher urgency than the regular work queue:
1436 # a stuck message risks missing the ~1h flush window (CustomerNotSubscribed = lost revenue).
1437 # A DLQ-depth signal alone fires too late (maxReceiveCount 5 x VisibilityTimeout 360s ~= 30
1438 # min already consumes half the window), so alarm on age at 15 min — well under the ~1h
1439 # window — to catch a stalled aggregator ESM before the window closes.
1440 DeprovisionWorkQueueAgeAlarm :
1441 Type : AWS::CloudWatch::Alarm
1442 Condition : IsFullPipeline
1443 Properties :
1444 AlarmName : !Sub ${StackPrefix}-deprovision-work-queue-age
1445 AlarmDescription : Deprovision-work queue messages aging - a deprovisioning flush is stalled and risks missing the ~1h window
1446 Namespace : AWS/SQS
1447 MetricName : ApproximateAgeOfOldestMessage
1448 Dimensions :
1449 - Name : QueueName
1450 Value : !GetAtt DeprovisionWorkQueue.QueueName
1451 Statistic : Maximum
1452 Period : 300
1453 EvaluationPeriods : 1
1454 Threshold : 900
1455 ComparisonOperator : GreaterThanThreshold
1456 TreatMissingData : notBreaching
1457 AlarmActions :
1458 - !If [ HasAlertsTopic , !Ref AlertsTopicArn , !Ref 'AWS::NoValue' ]
1459
1460 # 3d) Aggregator throttles — the aggregator has no reserved concurrency (only an ESM
1461 # MaximumConcurrency cap), so account-level concurrency pressure can throttle it,
1462 # stalling aggregation. Caught here directly rather than only via the eventual DLQ.
1463 AggregatorThrottleAlarm :
1464 Type : AWS::CloudWatch::Alarm
1465 Condition : IsFullPipeline
1466 Properties :
1467 AlarmName : !Sub ${StackPrefix}-aggregator-throttles
1468 AlarmDescription : Aggregator throttled - usage may not be aggregated in time
1469 Namespace : AWS/Lambda
1470 MetricName : Throttles
1471 Dimensions :
1472 - Name : FunctionName
1473 Value : !Ref AggregatorFunction
1474 Statistic : Sum
1475 Period : 3600
1476 EvaluationPeriods : 1
1477 Threshold : 1
1478 ComparisonOperator : GreaterThanOrEqualToThreshold
1479 TreatMissingData : notBreaching
1480 AlarmActions :
1481 - !If [ HasAlertsTopic , !Ref AlertsTopicArn , !Ref 'AWS::NoValue' ]
1482
1483 # 3e) CustomerNotSubscribed — a TERMINAL per-record BatchMeterUsage status (NOT retried,
1484 # so it never raises a Lambda error). A spike means real usage is being dropped
1485 # (mass cancellation window, or a bug submitting for inactive licenses) — revenue
1486 # loss that would otherwise be SILENT. EMF metric emitted by the submitter.
1487 CustomerNotSubscribedAlarm :
1488 Type : AWS::CloudWatch::Alarm
1489 Properties :
1490 AlarmName : !Sub ${StackPrefix}-customer-not-subscribed
1491 AlarmDescription : BatchMeterUsage returned CustomerNotSubscribed - usage dropped for inactive/expired licenses
1492 Namespace : AwsMarketplace/Metering
1493 MetricName : CustomerNotSubscribed
1494 Dimensions :
1495 - Name : ProductCode
1496 Value : !Ref ProductCode
1497 Statistic : Sum
1498 Period : 3600
1499 EvaluationPeriods : 1
1500 Threshold : 1
1501 ComparisonOperator : GreaterThanOrEqualToThreshold
1502 TreatMissingData : notBreaching
1503 AlarmActions :
1504 - !If [ HasAlertsTopic , !Ref AlertsTopicArn , !Ref 'AWS::NoValue' ]
1505
1506 # 3f) BatchMeterUsageException — a REQUEST-level BatchMeterUsage exception
1507 # (InvalidUsageDimensionException, TimestampOutOfBoundsException, InvalidTagException,
1508 # InvalidProductCodeException, InvalidLicenseException, InvalidUsageAllocationsException, ...) that does NOT raise a Lambda error. The submitter isolates the
1509 # offending record (bisect) and, when it stamps that record's real terminal status, emits this
1510 # metric dimensioned by [Exception, ProductCode]. Any of these means usage was NOT billed
1511 # (mis-config / bad input) — silent revenue loss without this alarm. The alarm sums across all
1512 # exception types via a Metrics Insights expression (the per-exception breakdown is on the
1513 # dashboard "BatchMeterUsage exceptions by type" widget).
1514 BatchMeterUsageExceptionAlarm :
1515 Type : AWS::CloudWatch::Alarm
1516 Properties :
1517 AlarmName : !Sub ${StackPrefix}-batchmeterusage-exception
1518 AlarmDescription : BatchMeterUsage returned a request-level exception (dimension/timestamp/tag/product-code/license) - usage not billed
1519 Metrics :
1520 - Id : e1
1521 Expression : !Sub "SELECT SUM(BatchMeterUsageException) FROM \" AwsMarketplace/Metering \" WHERE ProductCode = '${ProductCode}'"
1522 Label : BatchMeterUsageException (all types)
1523 ReturnData : true
1524 Period : 3600
1525 EvaluationPeriods : 1
1526 Threshold : 1
1527 ComparisonOperator : GreaterThanOrEqualToThreshold
1528 TreatMissingData : notBreaching
1529 AlarmActions :
1530 - !If [ HasAlertsTopic , !Ref AlertsTopicArn , !Ref 'AWS::NoValue' ]
1531
1532 # 4a) Raw usage aged out BEFORE aggregation (>24h) — a raw row the discoverer aged out
1533 # because it was never rolled into an aggregated record. The discoverer emits the EMF
1534 # `UsageAggregationExpired` metric (namespace AwsMarketplace/Metering, dimension
1535 # ProductCode), CloudWatch auto-publishes it (no MetricFilter — that would double-ingest),
1536 # and this alarm watches it. Distinct from 4b so an operator sees WHICH stage lost revenue.
1537 UsageAggregationExpiredAlarm :
1538 Type : AWS::CloudWatch::Alarm
1539 Condition : IsFullPipeline
1540 Properties :
1541 AlarmName : !Sub ${StackPrefix}-usage-aggregation-expired
1542 AlarmDescription : Raw usage aged out past 24h before it was aggregated (lost before aggregation)
1543 Namespace : AwsMarketplace/Metering
1544 MetricName : UsageAggregationExpired
1545 Dimensions :
1546 - Name : ProductCode
1547 Value : !Ref ProductCode
1548 Statistic : Sum
1549 Period : 3600
1550 EvaluationPeriods : 1
1551 Threshold : 1
1552 ComparisonOperator : GreaterThanOrEqualToThreshold
1553 TreatMissingData : notBreaching
1554 AlarmActions :
1555 - !If [ HasAlertsTopic , !Ref AlertsTopicArn , !Ref 'AWS::NoValue' ]
1556
1557 # 4b) Aggregated record expired BEFORE submission — a record that WAS aggregated but the
1558 # submitter never drained before the 24h window (or the month-end grace) closed. The
1559 # submission-expiry Lambda emits the EMF `UsageSubmissionExpired` metric. Distinct from 4a.
1560 UsageSubmissionExpiredAlarm :
1561 Type : AWS::CloudWatch::Alarm
1562 Properties :
1563 AlarmName : !Sub ${StackPrefix}-usage-submission-expired
1564 AlarmDescription : Aggregated record aged out before submission (aggregated but never billed)
1565 Namespace : AwsMarketplace/Metering
1566 MetricName : UsageSubmissionExpired
1567 Dimensions :
1568 - Name : ProductCode
1569 Value : !Ref ProductCode
1570 Statistic : Sum
1571 Period : 3600
1572 EvaluationPeriods : 1
1573 Threshold : 1
1574 ComparisonOperator : GreaterThanOrEqualToThreshold
1575 TreatMissingData : notBreaching
1576 AlarmActions :
1577 - !If [ HasAlertsTopic , !Ref AlertsTopicArn , !Ref 'AWS::NoValue' ]
1578
1579 # 5) Client-side rejections — usage rejected before submit for a specific,
1580 # correctable reason (bad quantity/dimension/timestamp/identifier/allocation). The
1581 # meter Lambda emits the EMF `UsageRecordRejected` metric on the [ProductCode]
1582 # dimension (this alarm) AND on [Reason, ProductCode] so the seller can slice by
1583 # reason, then fix at their source and resubmit (the row was never sent, so it is
1584 # still meterable within 24h). Seller-owned action, like the other alarms.
1585 UsageRecordRejectedAlarm :
1586 Type : AWS::CloudWatch::Alarm
1587 Condition : IsFullPipeline
1588 Properties :
1589 AlarmName : !Sub ${StackPrefix}-usage-rejected
1590 AlarmDescription : Usage rejected client-side before metering - seller must fix the source rows and resubmit
1591 # UsageRecordRejected is emitted on a single [Reason, MeteringMode, ProductCode] dimension set
1592 # (no bare [ProductCode] series — that would resurface an "Other" bucket on the by-Reason
1593 # widget and, if also emitted, double-count it). Sum across all reasons via Metrics Insights
1594 # so the alarm still fires regardless of which reason codes are present (the per-reason
1595 # breakdown is on the "Client-side rejections by reason" dashboard widget).
1596 Metrics :
1597 - Id : e1
1598 Expression : !Sub "SELECT SUM(UsageRecordRejected) FROM \" AwsMarketplace/Metering \" WHERE ProductCode = '${ProductCode}'"
1599 Label : UsageRecordRejected (all reasons)
1600 ReturnData : true
1601 Period : 3600
1602 EvaluationPeriods : 1
1603 Threshold : 1
1604 ComparisonOperator : GreaterThanOrEqualToThreshold
1605 TreatMissingData : notBreaching
1606 AlarmActions :
1607 - !If [ HasAlertsTopic , !Ref AlertsTopicArn , !Ref 'AWS::NoValue' ]
1608
1609 UsageRecordUnprocessedAlarm :
1610 Type : AWS::CloudWatch::Alarm
1611 Properties :
1612 AlarmName : !Sub ${StackPrefix}-usage-unprocessed
1613 AlarmDescription : >-
1614 BatchMeterUsage returned UnprocessedRecords that were STILL unprocessed after the
1615 submitter's one automatic retry. These records were NOT metered on this run; they are
1616 left pending and retried on the next submitter cycle. A persistent (repeating) breach
1617 means records are being rejected/failing to meter and need investigation (throttling,
1618 timestamp bounds, or a downstream issue) - the seller may lose revenue if it does not clear.
1619 # UsageRecordUnprocessed is emitted on the bare [ProductCode] dimension (count of
1620 # still-unprocessed records after the single retry). Sum across the product via Metrics
1621 # Insights so the alarm is robust regardless of dimension presence, consistent with the
1622 # UsageRecordRejected alarm above. The submitter does NOT raise on this (it returns and
1623 # leaves the records pending), so this metric is the ONLY alarm-able signal for it -
1624 # it does not trip the Lambda Errors alarm.
1625 Metrics :
1626 - Id : e1
1627 Expression : !Sub "SELECT SUM(UsageRecordUnprocessed) FROM \" AwsMarketplace/Metering \" WHERE ProductCode = '${ProductCode}'"
1628 Label : UsageRecordUnprocessed (still unprocessed after retry)
1629 ReturnData : true
1630 Period : 3600
1631 EvaluationPeriods : 1
1632 Threshold : 1
1633 ComparisonOperator : GreaterThanOrEqualToThreshold
1634 TreatMissingData : notBreaching
1635 AlarmActions :
1636 - !If [ HasAlertsTopic , !Ref AlertsTopicArn , !Ref 'AWS::NoValue' ]
1637
1638 # ── Health dashboard (per-product; ) ─────────────────────────────────────
1639 # Complete pipeline visibility in ONE place so an operator sees health without drilling
1640 # into individual alarms. HEALTH-only (no business metrics). Created by default; set
1641 # CreateDashboard=false to skip, or delete/customize after deploy.
1642 MeteringHealthDashboard :
1643 Type : AWS::CloudWatch::Dashboard
1644 Condition : CreateFullDashboard
1645 Properties :
1646 # Dashboard names are account-global (NOT scoped per region), so the same
1647 # per-product StackPrefix deployed to two Regions would collide. Keep the
1648 # Region in the name to make it unique per Region.
1649 DashboardName : !Sub ${StackPrefix}-metering-health-${AWS::Region}
1650 DashboardBody : !Sub |
1651 {
1652 "widgets": [
1653 {
1654 "type": "text", "x": 0, "y": 0, "width": 24, "height": 1,
1655 "properties": {"markdown": "# Metering pipeline health — ${StackPrefix} (product ${ProductCode})"}
1656 },
1657 {
1658 "type": "metric", "x": 0, "y": 1, "width": 12, "height": 6,
1659 "properties": {
1660 "title": "Lambda Errors (all stages)",
1661 "region": "${AWS::Region}", "stat": "Sum", "period": 300,
1662 "metrics": [
1663 ["AWS/Lambda", "Errors", "FunctionName", "${DiscovererFunction}", {"label": "discoverer"}],
1664 ["...", "${AggregatorFunction}", {"label": "aggregator"}],
1665 ["...", "${CleanupFunction}", {"label": "cleanup"}],
1666 ["...", "${SubmitterFunction}", {"label": "submitter"}]
1667 ]
1668 }
1669 },
1670 {
1671 "type": "metric", "x": 12, "y": 1, "width": 12, "height": 6,
1672 "properties": {
1673 "title": "Lambda Throttles (all stages)",
1674 "region": "${AWS::Region}", "stat": "Sum", "period": 300,
1675 "metrics": [
1676 ["AWS/Lambda", "Throttles", "FunctionName", "${DiscovererFunction}", {"label": "discoverer"}],
1677 ["...", "${AggregatorFunction}", {"label": "aggregator"}],
1678 ["...", "${CleanupFunction}", {"label": "cleanup"}],
1679 ["...", "${SubmitterFunction}", {"label": "submitter"}]
1680 ]
1681 }
1682 },
1683 {
1684 "type": "metric", "x": 0, "y": 7, "width": 12, "height": 6,
1685 "properties": {
1686 "title": "Lambda Invocations (all stages)",
1687 "region": "${AWS::Region}", "stat": "Sum", "period": 300,
1688 "metrics": [
1689 ["AWS/Lambda", "Invocations", "FunctionName", "${DiscovererFunction}", {"label": "discoverer"}],
1690 ["...", "${AggregatorFunction}", {"label": "aggregator"}],
1691 ["...", "${CleanupFunction}", {"label": "cleanup"}],
1692 ["...", "${SubmitterFunction}", {"label": "submitter"}]
1693 ]
1694 }
1695 },
1696 {
1697 "type": "metric", "x": 12, "y": 7, "width": 12, "height": 6,
1698 "properties": {
1699 "title": "Lambda Duration p95 (ms)",
1700 "region": "${AWS::Region}", "stat": "p95", "period": 300,
1701 "metrics": [
1702 ["AWS/Lambda", "Duration", "FunctionName", "${DiscovererFunction}", {"label": "discoverer"}],
1703 ["...", "${AggregatorFunction}", {"label": "aggregator"}],
1704 ["...", "${CleanupFunction}", {"label": "cleanup"}],
1705 ["...", "${SubmitterFunction}", {"label": "submitter"}]
1706 ]
1707 }
1708 },
1709 {
1710 "type": "metric", "x": 0, "y": 13, "width": 12, "height": 6,
1711 "properties": {
1712 "title": "Queue depth (visible messages)",
1713 "region": "${AWS::Region}", "stat": "Maximum", "period": 300,
1714 "metrics": [
1715 ["AWS/SQS", "ApproximateNumberOfMessagesVisible", "QueueName", "${MeteringWorkQueue.QueueName}", {"label": "work"}],
1716 ["...", "${DeprovisionWorkQueue.QueueName}", {"label": "deprovision-work"}],
1717 ["...", "${MeteringCleanupQueue.QueueName}", {"label": "cleanup"}],
1718 ["...", "${MeteringWorkDLQ.QueueName}", {"label": "work-DLQ"}],
1719 ["...", "${DeprovisionWorkDLQ.QueueName}", {"label": "deprovision-work-DLQ"}],
1720 ["...", "${MeteringCleanupDLQ.QueueName}", {"label": "cleanup-DLQ"}]
1721 ]
1722 }
1723 },
1724 {
1725 "type": "metric", "x": 12, "y": 13, "width": 12, "height": 6,
1726 "properties": {
1727 "title": "Work queue oldest-message age (s)",
1728 "region": "${AWS::Region}", "stat": "Maximum", "period": 300,
1729 "metrics": [
1730 ["AWS/SQS", "ApproximateAgeOfOldestMessage", "QueueName", "${MeteringWorkQueue.QueueName}", {"label": "work"}],
1731 ["...", "${DeprovisionWorkQueue.QueueName}", {"label": "deprovision-work"}],
1732 ["...", "${MeteringCleanupQueue.QueueName}", {"label": "cleanup"}]
1733 ]
1734 }
1735 },
1736 {
1737 "type": "metric", "x": 0, "y": 19, "width": 12, "height": 6,
1738 "properties": {
1739 "title": "Client-side rejections by reason (UsageRecordRejected)",
1740 "region": "${AWS::Region}", "period": 3600, "view": "timeSeries",
1741 "metrics": [
1742 [ { "expression": "SELECT SUM(UsageRecordRejected) FROM \"AwsMarketplace/Metering\" WHERE ProductCode = '${ProductCode}' GROUP BY Reason ORDER BY SUM() DESC", "label": "by reason", "id": "q1", "period": 3600 } ]
1743 ],
1744 "yAxis": {"left": {"min": 0}}
1745 }
1746 },
1747 {
1748 "type": "metric", "x": 12, "y": 19, "width": 12, "height": 6,
1749 "properties": {
1750 "title": "Terminal statuses (submit outcomes + expiry by stage)",
1751 "region": "${AWS::Region}", "stat": "Sum", "period": 3600,
1752 "metrics": [
1753 ["AwsMarketplace/Metering", "CustomerNotSubscribed", "ProductCode", "${ProductCode}", {"label": "CustomerNotSubscribed"}],
1754 ["AwsMarketplace/Metering", "UsageAggregationExpired", "ProductCode", "${ProductCode}", {"label": "AggregationExpired (raw, never aggregated)"}],
1755 ["AwsMarketplace/Metering", "UsageSubmissionExpired", "ProductCode", "${ProductCode}", {"label": "SubmissionExpired (aggregated, never submitted)"}],
1756 ["AwsMarketplace/Metering", "DuplicateRecord", "ProductCode", "${ProductCode}", {"label": "DuplicateRecord"}],
1757 ["AwsMarketplace/Metering", "UsageRecordUnprocessed", "ProductCode", "${ProductCode}", {"label": "UnprocessedAfterRetry"}]
1758 ]
1759 }
1760 },
1761 {
1762 "type": "metric", "x": 0, "y": 25, "width": 12, "height": 6,
1763 "properties": {
1764 "title": "BatchMeterUsage exceptions by type (request-level, usage NOT billed)",
1765 "region": "${AWS::Region}", "period": 3600, "view": "timeSeries",
1766 "metrics": [
1767 [ { "expression": "SELECT SUM(BatchMeterUsageException) FROM \"AwsMarketplace/Metering\" WHERE ProductCode = '${ProductCode}' GROUP BY Exception ORDER BY SUM() DESC", "label": "by exception", "id": "x1", "period": 3600 } ]
1768 ],
1769 "yAxis": {"left": {"min": 0}}
1770 }
1771 }
1772 ]
1773 }
1774
1775 # Direct-submit health dashboard: the seller writes finalized records straight to
1776 # aggregated_usage, so only the submitter + submission-expiry exist. No discoverer/
1777 # aggregator/cleanup, no work/cleanup queues, no aggregation-stage metrics.
1778 MeteringHealthDashboardDirect :
1779 Type : AWS::CloudWatch::Dashboard
1780 Condition : CreateDirectDashboard
1781 Properties :
1782 # Account-global name; keep the Region so the same StackPrefix in another
1783 # Region does not collide. (Full vs direct are mutually exclusive by
1784 # Condition, so they intentionally share the same per-Region name.)
1785 DashboardName : !Sub ${StackPrefix}-metering-health-${AWS::Region}
1786 DashboardBody : !Sub |
1787 {
1788 "widgets": [
1789 {
1790 "type": "metric", "x": 0, "y": 0, "width": 12, "height": 6,
1791 "properties": {
1792 "title": "Submitter / submission-expiry Lambda health",
1793 "region": "${AWS::Region}", "stat": "Sum", "period": 300,
1794 "metrics": [
1795 ["AWS/Lambda", "Errors", "FunctionName", "${SubmitterFunction}", {"label": "submitter errors"}],
1796 ["AWS/Lambda", "Throttles", "FunctionName", "${SubmitterFunction}", {"label": "submitter throttles"}],
1797 ["AWS/Lambda", "Invocations", "FunctionName", "${SubmitterFunction}", {"label": "submitter invocations"}],
1798 ["AWS/Lambda", "Errors", "FunctionName", "${SubmissionExpiryFunction}", {"label": "expiry errors"}]
1799 ]
1800 }
1801 },
1802 {
1803 "type": "metric", "x": 12, "y": 0, "width": 12, "height": 6,
1804 "properties": {
1805 "title": "Terminal statuses (submit outcomes + submission-expiry)",
1806 "region": "${AWS::Region}", "stat": "Sum", "period": 3600,
1807 "metrics": [
1808 ["AwsMarketplace/Metering", "CustomerNotSubscribed", "ProductCode", "${ProductCode}", {"label": "CustomerNotSubscribed"}],
1809 ["AwsMarketplace/Metering", "DuplicateRecord", "ProductCode", "${ProductCode}", {"label": "DuplicateRecord"}],
1810 ["AwsMarketplace/Metering", "UsageSubmissionExpired", "ProductCode", "${ProductCode}", {"label": "SubmissionExpired (never submitted)"}],
1811 ["AwsMarketplace/Metering", "UsageRecordUnprocessed", "ProductCode", "${ProductCode}", {"label": "UnprocessedAfterRetry"}]
1812 ]
1813 }
1814 },
1815 {
1816 "type": "metric", "x": 0, "y": 6, "width": 12, "height": 6,
1817 "properties": {
1818 "title": "BatchMeterUsage exceptions by type (request-level, usage NOT billed)",
1819 "region": "${AWS::Region}", "period": 3600, "view": "timeSeries",
1820 "metrics": [
1821 [ { "expression": "SELECT SUM(BatchMeterUsageException) FROM \"AwsMarketplace/Metering\" WHERE ProductCode = '${ProductCode}' GROUP BY Exception ORDER BY SUM() DESC", "label": "by exception", "id": "x1", "period": 3600 } ]
1822 ],
1823 "yAxis": {"left": {"min": 0}}
1824 }
1825 }
1826 ]
1827 }
1828
1829 Outputs :
1830 RegistrationUrl :
1831 Description : Set this as your SaaS fulfillment URL in AWS Marketplace Management Portal
1832 Value : !Sub https://${RegistrationApi}.execute-api.${AWS::Region}.amazonaws.com/${StageName}/register
1833 UsageTableName :
1834 Condition : IsFullPipeline
1835 Value : !Ref UsageTable
1836 AggregatedUsageTableName :
1837 Value : !Ref AggregatedUsageTable
1838 DiscovererFunctionName :
1839 Condition : IsFullPipeline
1840 Value : !Ref DiscovererFunction
1841 AggregatorFunctionName :
1842 Condition : IsFullPipeline
1843 Value : !Ref AggregatorFunction
1844 CleanupFunctionName :
1845 Condition : IsFullPipeline
1846 Value : !Ref CleanupFunction
1847 SubmitterFunctionName :
1848 Value : !Ref SubmitterFunction
1849 SubmissionExpiryFunctionName :
1850 Value : !Ref SubmissionExpiryFunction