Setting the file. One moment.
Query Metering · 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
scripts/ query_metering.py
Python · 558 lines · 25 KB
for complex/open-ended analytics; and (for CA/LicenseArn metering with no
15 LicenseArn->productCode mapping) only non-product-scoped queries.
16
17 Scope details:
18 - Queries older than 90 days are out of scope: this script only reads CloudTrail
19 Event History (90-day retention). If the requested range is entirely older than
20 90 days it does NOT query — it prints a recommendation to use the seller-run
21 Seller Reports / SDDS path. If the range only partly predates 90 days, it queries
22 the in-window portion.
23 - Bounded fetch (page cap + in-memory): the fetch paginates up to --max-pages
24 (default 20 x 50 = ~1000 events) then filters/aggregates in memory. A high-volume
25 window can exceed the cap, so the output carries an explicit completeness signal:
26 ``complete``/``truncated`` + the pages/events fetched. When truncated, aggregate
27 results (summary/count/top_customers) are a LOWER-BOUND sample and are flagged as
28 NOT authoritative — narrow the window / raise --max-pages, or use Seller Reports for
29 an authoritative total.
30 - Product-specific queries (--product-code) require a LicenseArn->productCode
31 mapping. CA (LicenseArn) metering carries no productCode in the BatchMeterUsage
32 API, so provide it via --deployment-product-code (single-product stack), an
33 additionalEventData mapping in the events, or a legacy request-level ProductCode.
34 Without a mapping, product-specific queries are unsupported (non-product-scoped
35 queries — by customer, dimension, status, time — are still available).
36
37 Prerequisites:
38 - Python 3.12+
39 - boto3 (pip install boto3)
40 - AWS credentials with cloudtrail:LookupEvents permission
41 """
42
43 import argparse
44 import json
45 import sys
46 from collections import Counter
47 from datetime import datetime, timedelta, timezone
48
49 import boto3
50
51 EVENT_SOURCE = "metering-marketplace.amazonaws.com"
52 MAX_RESULTS_PER_PAGE = 50
53
54
55 # ─── Tier 1: CloudTrail Event History (LookupEvents) ────────────────────────
56
57
58 def lookup_events (client, start_time, end_time, max_pages = 20 , fetch_stats = None ):
59 """Paginate through CloudTrail LookupEvents for metering events.
60
61 The fetch is page-bounded. If ``fetch_stats`` (a dict) is provided, it is
62 populated with ``pagesFetched`` and ``truncated`` — ``truncated`` is True when the
63 page cap was reached while a ``NextToken`` still remained (more events were available
64 than were fetched), so the caller can flag a partial result rather than presenting a
65 page-capped answer as complete.
66 """
67 kwargs = {
68 "LookupAttributes" : [{ "AttributeKey" : "EventSource" , "AttributeValue" : EVENT_SOURCE }],
69 "StartTime" : start_time,
70 "EndTime" : end_time,
71 "MaxResults" : MAX_RESULTS_PER_PAGE ,
72 }
73
74 pages = 0
75 truncated = False
76 while pages < max_pages:
77 response = client.lookup_events( ** kwargs)
78 pages += 1
79 for event in response.get( "Events" , []):
80 try :
81 cloud_trail_event = json.loads(event.get( "CloudTrailEvent" , " {} " ))
82 yield event[ "EventTime" ], cloud_trail_event
83 except (json.JSONDecodeError, KeyError ):
84 continue
85
86 next_token = response.get( "NextToken" )
87 if not next_token:
88 break
89 kwargs[ "NextToken" ] = next_token
90 if pages >= max_pages:
91 # Hit the page cap with more events still available — the result is a
92 # bounded sample, not the full window.
93 truncated = True
94 break
95 if fetch_stats is not None :
96 fetch_stats[ "pagesFetched" ] = pages
97 fetch_stats[ "truncated" ] = truncated
98
99
100 def extract_records_from_event (event_time, ct_event):
101 """Extract metering records from a CloudTrail event (request + response).
102
103 Also resolves each record's productCode where possible:
104 - Legacy ProductCode metering carries `ProductCode` at the request level.
105 - CA (LicenseArn) metering carries NO productCode in the request/response, but
106 the event MAY include an `additionalEventData` LicenseArn->productCode mapping.
107 A record's `productCode` is "" when it cannot be resolved from the event alone.
108 """
109 records = []
110 # CloudTrail may include these keys with a JSON `null` value (e.g. an errored
111 # BatchMeterUsage — a request-level exception like InvalidUsageDimensionException has no
112 # `results`, so responseElements is null). `.get(key, {})` only defaults when the key is
113 # ABSENT; a present-but-null value returns None, so `.get("results", [])` on the next line
114 # would raise AttributeError and crash the whole query. Coerce null -> {} with `or {}`
115 # (matching the additionalEventData guard).
116 request_params = ct_event.get( "requestParameters" , {}) or {}
117 response_elements = ct_event.get( "responseElements" , {}) or {}
118 additional = ct_event.get( "additionalEventData" , {}) or {}
119
120 usage_records = request_params.get( "usageRecords" , [])
121 results = response_elements.get( "results" , [])
122
123 # LicenseArn->productCode mapping from the CloudTrail additionalEventData, if present.
124 # Accept a couple of shapes defensively (a direct map, or a list of {licenseArn, productCode}).
125 license_to_product = {}
126 raw_map = additional.get( "licenseArnToProductCode" ) or additional.get( "productCodeByLicenseArn" )
127 if isinstance (raw_map, dict ):
128 license_to_product = { str (k): str (v) for k, v in raw_map.items()}
129 elif isinstance (raw_map, list ):
130 for entry in raw_map:
131 if isinstance (entry, dict ) and entry.get( "licenseArn" ):
132 license_to_product[ str (entry[ "licenseArn" ])] = str (entry.get( "productCode" , "" ))
133
134 # Legacy path: ProductCode is at the request level.
135 request_product_code = request_params.get( "productCode" , "" )
136
137 # Build a lookup from results by matching on record fields
138 # Include licenseArn in key to avoid collisions for CA multi-license accounts
139 result_map = {}
140 for r in results:
141 ur = r.get( "usageRecord" , {})
142 key = (
143 ur.get( "customerAWSAccountId" , "" ),
144 ur.get( "dimension" , "" ),
145 str (ur.get( "timestamp" , "" )),
146 ur.get( "licenseArn" , "" ),
147 )
148 result_map[key] = r
149
150 for ur in usage_records:
151 key = (
152 ur.get( "customerAWSAccountId" , "" ),
153 ur.get( "dimension" , "" ),
154 str (ur.get( "timestamp" , "" )),
155 ur.get( "licenseArn" , "" ),
156 )
157 result = result_map.get(key, {})
158
159 license_arn = ur.get( "licenseArn" , "" )
160 # Resolve productCode: request-level ProductCode (legacy) first, then the
161 # additionalEventData mapping (CA). "" if neither is available.
162 product_code = request_product_code or license_to_product.get(license_arn, "" )
163
164 records.append(
165 {
166 "eventTime" : str (event_time),
167 "customerAWSAccountId" : ur.get( "customerAWSAccountId" , "" ),
168 "customerIdentifier" : ur.get( "customerIdentifier" , "" ),
169 "licenseArn" : license_arn,
170 "productCode" : product_code,
171 "dimension" : ur.get( "dimension" , "" ),
172 "quantity" : ur.get( "quantity" , 0 ),
173 "timestamp" : ur.get( "timestamp" , "" ),
174 "status" : result.get( "status" , "unknown" ),
175 "meteringRecordId" : result.get( "meteringRecordId" , "" ),
176 "usageAllocations" : ur.get( "usageAllocations" , []),
177 }
178 )
179
180 return records
181
182
183 # ─── Filtering & Aggregation ─────────────────────────────────────────────────
184
185
186 def matches_filters (record, args):
187 """Check if a record passes user-specified filters."""
188 if args.customer_id and record[ "customerAWSAccountId" ] != args.customer_id:
189 return False
190 if args.status and record[ "status" ] != args.status:
191 return False
192 if args.dimension and record[ "dimension" ] != args.dimension:
193 return False
194 if args.metering_record_id and record[ "meteringRecordId" ] != args.metering_record_id:
195 return False
196 if args.product_code and record.get( "productCode" , "" ) != args.product_code:
197 return False
198 ts = str (record.get( "timestamp" , "" ))[: 10 ] if record.get( "timestamp" ) else ""
199 if args.start_date and ts and ts < args.start_date:
200 return False
201 if args.end_date and ts and ts > args.end_date:
202 return False
203 return True
204
205
206 def aggregate (records, query_type, top_n = 10 , limit = 50 ):
207 """Aggregate filtered records into output format."""
208 if not records:
209 return {
210 "totalRecords" : 0 ,
211 "message" : "No records match the given filters." ,
212 "hint" : "For data beyond 90 days, check Seller Reports at https://aws.amazon.com/marketplace/management/reports/" ,
213 }
214
215 total_qty = sum (r[ "quantity" ] for r in records)
216 status_counts = Counter(r[ "status" ] for r in records)
217 dim_counts = Counter(r[ "dimension" ] for r in records)
218 dim_qty: Counter = Counter()
219 cust_qty: Counter = Counter()
220 cust_counts: Counter = Counter()
221 dates = []
222
223 for r in records:
224 dim_qty[r[ "dimension" ]] += r[ "quantity" ]
225 cust_qty[r[ "customerAWSAccountId" ]] += r[ "quantity" ]
226 cust_counts[r[ "customerAWSAccountId" ]] += 1
227 ts = str (r.get( "timestamp" , "" ))[: 10 ] if r.get( "timestamp" ) else ""
228 if ts:
229 dates.append(ts)
230
231 base = {
232 "totalRecords" : len (records),
233 "totalUsageQuantity" : total_qty,
234 }
235
236 if query_type == "summary" :
237 return {
238 ** base,
239 "byStatus" : dict (status_counts),
240 "byDimension" : {
241 d: { "records" : dim_counts[d], "totalQuantity" : dim_qty[d]} for d in dim_counts
242 },
243 "uniqueCustomers" : len (cust_counts),
244 "topCustomersByUsage" : [
245 { "customer" : c, "totalQuantity" : q} for c, q in cust_qty.most_common(top_n)
246 ],
247 "dateRange" : {
248 "earliest" : min (dates) if dates else None ,
249 "latest" : max (dates) if dates else None ,
250 },
251 }
252 elif query_type == "top_customers" :
253 return {
254 ** base,
255 "uniqueCustomers" : len (cust_counts),
256 "topCustomersByUsage" : [
257 { "customer" : c, "totalQuantity" : q} for c, q in cust_qty.most_common(top_n)
258 ],
259 "topCustomersByRecordCount" : [
260 { "customer" : c, "records" : n} for c, n in cust_counts.most_common(top_n)
261 ],
262 }
263 elif query_type == "count" :
264 return {
265 ** base,
266 "byStatus" : dict (status_counts),
267 "byDimension" : {
268 d: { "records" : dim_counts[d], "totalQuantity" : dim_qty[d]} for d in dim_counts
269 },
270 }
271 elif query_type == "list_failures" :
272 failures = [r for r in records if r[ "status" ] != "Success" ]
273 return {
274 ** base,
275 "totalFailures" : len (failures),
276 "byStatus" : dict (Counter(r[ "status" ] for r in failures)),
277 "records" : failures[:limit],
278 }
279 else : # detail
280 return {
281 ** base,
282 "showing" : min (limit, len (records)),
283 "records" : records[:limit],
284 }
285
286
287 # ─── Main ────────────────────────────────────────────────────────────────────
288
289
290 def main ():
291 parser = argparse.ArgumentParser(
292 description = "Query AWS Marketplace metering records via CloudTrail"
293 )
294 parser.add_argument(
295 "--region" ,
296 required = True ,
297 help = "AWS region where BatchMeterUsage is called (REQUIRED — no default)" ,
298 )
299 parser.add_argument(
300 "--days" ,
301 type = int ,
302 default = 7 ,
303 help = "Look back N days (default: 7, max: 90 for Event History)" ,
304 )
305 parser.add_argument( "--start-date" , help = "Start date (YYYY-MM-DD). Overrides --days." )
306 parser.add_argument( "--end-date" , help = "End date (YYYY-MM-DD). Defaults to now." )
307 parser.add_argument( "--customer-id" , help = "Filter by CustomerAWSAccountId" )
308 parser.add_argument(
309 "--status" , help = "Filter by status (Success, CustomerNotSubscribed, DuplicateRecord)"
310 )
311 parser.add_argument( "--dimension" , help = "Filter by usage dimension" )
312 parser.add_argument( "--metering-record-id" , help = "Filter by MeteringRecordId" )
313 parser.add_argument(
314 "--product-code" ,
315 help = (
316 "Filter to a specific product (product-specific query). Requires a "
317 "LicenseArn->productCode mapping for CA/LicenseArn-based metering: either "
318 "--deployment-product-code (single-product stack), an additionalEventData "
319 "mapping in the events, or (legacy) request-level ProductCode. Without a "
320 "mapping, product-specific queries are unsupported."
321 ),
322 )
323 parser.add_argument(
324 "--deployment-product-code" ,
325 help = (
326 "Single-product deployment context: the productCode this stack/usage table "
327 "meters (e.g. from awsmp-<productCode>-metering). Supplies the "
328 "LicenseArn->productCode mapping when all events belong to one product."
329 ),
330 )
331 parser.add_argument(
332 "--query-type" ,
333 default = "summary" ,
334 choices = [ "detail" , "summary" , "count" , "list_failures" , "top_customers" ],
335 help = "Output format (default: summary)" ,
336 )
337 parser.add_argument( "--top-n" , type = int , default = 10 , help = "Number of top results (default: 10)" )
338 parser.add_argument(
339 "--limit" , type = int , default = 50 , help = "Max records for detail queries (default: 50)"
340 )
341 parser.add_argument(
342 "--max-pages" ,
343 type = int ,
344 default = 20 ,
345 help = "Max pages to fetch (default: 20, each page = 50 events)" ,
346 )
347 args = parser.parse_args()
348
349 # Calculate time range
350 now = datetime.now(timezone.utc)
351 if args.start_date:
352 start_time = datetime.strptime(args.start_date, "%Y-%m- %d " ).replace( tzinfo = timezone.utc)
353 else :
354 start_time = now - timedelta( days = args.days)
355
356 if args.end_date:
357 end_time = datetime.strptime(args.end_date, "%Y-%m- %d " ).replace(
358 hour = 23 , minute = 59 , second = 59 , tzinfo = timezone.utc
359 )
360 else :
361 end_time = now
362
363 if start_time > end_time:
364 sys.exit(
365 f "Error: start_time ( { start_time: % Y -% m -% d } ) is after end_time "
366 f "( { end_time: % Y -% m -% d } ). Provide --start-date when using a past --end-date."
367 )
368
369 # ── Queries older than 90 days are out of scope (CloudTrail retains 90 days) ──
370 # CloudTrail Event History (the only source this script queries) retains 90
371 # days. This script does NOT query data older than 90 days; older data is
372 # available only via Seller Reports / SDDS, and querying the delivered feeds
373 # is out of scope (it requires the seller's one-time "Set up customer data
374 # storage" setup and their own query/ETL over the feeds). If the requested
375 # range is ENTIRELY older than 90 days, do not attempt the query — emit a
376 # recommendation instead.
377 ninety_days_ago = now - timedelta( days = 90 )
378 if end_time < ninety_days_ago:
379 recommendation = {
380 "query" : "not-performed" ,
381 "reason" : "out-of-scope: requested range is entirely older than 90 days" ,
382 "detail" : (
383 f "The requested range ends { end_time: % Y -% m -% d } , which is older than the "
384 "90-day CloudTrail Event History retention window. This tool only queries "
385 "CloudTrail Event History (the last 90 days) and does not query data older "
386 "than that."
387 ),
388 "recommendation" : (
389 "For data older than 90 days, use the Seller Reports / Seller Delivery Data "
390 "Feeds (SDDS) path. This is a seller-run path: complete the one-time "
391 "'Set up customer data storage' step in the AWS Marketplace Management Portal, "
392 "then query/ETL the CSV feeds delivered to your S3 bucket. "
393 "See references/seller-reports.md."
394 ),
395 "sellerReportsUrl" : "https://aws.amazon.com/marketplace/management/reports/" ,
396 }
397 json.dump(recommendation, sys.stdout, indent = 2 , default = str )
398 print ()
399 return
400
401 # Cap start_time at the 90-day retention boundary for the portion that IS
402 # within scope, then RE-VALIDATE the window: capping can push
403 # start_time past a near-boundary end_time.
404 window_clipped = False
405 requested_start = start_time
406 if start_time < ninety_days_ago:
407 window_clipped = True
408 sys.stderr.write(
409 "NOTE: CloudTrail Event History retains only 90 days; the query covers the "
410 "in-window portion of the requested range (from 90 days ago). For the older "
411 "portion, use Seller Reports / SDDS (see references/seller-reports.md). \n "
412 )
413 start_time = ninety_days_ago
414
415 if start_time > end_time:
416 # Re-validation AFTER the 90-day cap: a valid pre-cap range can
417 # become invalid once start_time is capped to now-90d.
418 sys.exit(
419 "Error: CloudTrail Event History retention limit reached — after capping the "
420 f "start date to 90 days ago ( { start_time: % Y -% m -% d } ) it is later than the "
421 f "requested end date ( { end_time: % Y -% m -% d } ). Use a more recent --end-date, or use "
422 "Seller Reports / SDDS for data older than 90 days (see references/seller-reports.md)."
423 )
424
425 session = boto3.Session( region_name = args.region)
426 client = session.client( "cloudtrail" )
427
428 sys.stderr.write(
429 "Querying CloudTrail Event History \n "
430 f " Region: { args.region }\n "
431 f " Range: { start_time.strftime( '%Y-%m- %d %H:%M' ) } to { end_time.strftime( '%Y-%m- %d %H:%M' ) }\n "
432 )
433
434 # ─── Fetch records ───────────────────────────────────────────────────
435 all_records = []
436
437 # CloudTrail Event History (LookupEvents)
438 event_count = 0
439 fetch_stats: dict = {}
440 for event_time, ct_event in lookup_events(
441 client, start_time, end_time, args.max_pages, fetch_stats
442 ):
443 event_count += 1
444 all_records.extend(extract_records_from_event(event_time, ct_event))
445 sys.stderr.write(
446 f "Processed { event_count } CloudTrail events → { len (all_records) } metering records \n "
447 )
448 truncated = bool (fetch_stats.get( "truncated" ))
449 if truncated:
450 sys.stderr.write(
451 f "WARNING: hit the --max-pages cap ( { args.max_pages } pages) with more events "
452 "available; this result is a PARTIAL sample of the window. Narrow the range / add "
453 "filters (--customer-id/--dimension/--status) or raise --max-pages for a complete "
454 "answer, or use Seller Reports for an authoritative total. \n "
455 )
456
457 # ── Product-specific queries require a LicenseArn->productCode mapping ──
458 if args.product_code:
459 # Source (a): single-product deployment context — every record from a
460 # single-product stack/usage table belongs to that product. Stamp it on
461 # records that could not resolve a productCode from the event itself.
462 if args.deployment_product_code:
463 for r in all_records:
464 if not r.get( "productCode" ):
465 r[ "productCode" ] = args.deployment_product_code
466
467 # A mapping is available if ANY record now carries a productCode, from any
468 # of the ordered sources: deployment context (above), additionalEventData,
469 # or legacy request-level ProductCode (both applied in extract_records).
470 mapping_available = any (r.get( "productCode" ) for r in all_records)
471
472 if not mapping_available and all_records:
473 refusal = {
474 "query" : "not-performed" ,
475 "reason" : "product-specific query unsupported: no LicenseArn->productCode mapping" ,
476 "detail" : (
477 "This metering uses LicenseArn-based (Concurrent Agreements) records, and "
478 "BatchMeterUsage carries no productCode in the API request/response, so a "
479 "CloudTrail event cannot be attributed to a product from the API fields "
480 "alone. No product mapping source was available: no --deployment-product-code "
481 "(single-product stack context), no LicenseArn->productCode mapping in the "
482 "events' additionalEventData, and no request-level ProductCode (legacy)."
483 ),
484 "howToEnable" : (
485 "Provide --deployment-product-code <productCode> if these events come from a "
486 "single-product stack, or query a legacy ProductCode-based product (whose "
487 "events carry ProductCode directly). A subscribers-table lookup "
488 "(licenseArn -> productCode) is another valid mapping source."
489 ),
490 "supportedNonProductQueries" : [
491 "by customer (--customer-id)" ,
492 "by dimension (--dimension)" ,
493 "by status (--status)" ,
494 "by time range (--start-date/--end-date/--days)" ,
495 ],
496 }
497 json.dump(refusal, sys.stdout, indent = 2 , default = str )
498 print ()
499 return
500
501 # ─── Filter ──────────────────────────────────────────────────────────
502 filtered = [r for r in all_records if matches_filters(r, args)]
503 sys.stderr.write( f "After filters: { len (filtered) } records \n " )
504
505 # ─── Aggregate & output ──────────────────────────────────────────────
506 output = aggregate(filtered, args.query_type, args.top_n, args.limit)
507 output[ "source" ] = "CloudTrail Event History"
508 output[ "region" ] = args.region
509 output[ "timeRange" ] = {
510 "start" : start_time.strftime( "%Y-%m- %d %H:%M:%S UTC" ),
511 "end" : end_time.strftime( "%Y-%m- %d %H:%M:%S UTC" ),
512 }
513 # Explicit result-completeness signal. The result is COMPLETE only if the fetch was not
514 # page-capped AND the requested window was not clipped at the 90-day retention boundary —
515 # never let the caller mistake a partial/clipped result for a whole-window one.
516 output[ "complete" ] = not (truncated or window_clipped)
517 output[ "truncated" ] = truncated
518 output[ "windowClipped" ] = window_clipped
519 output[ "fetch" ] = {
520 "pagesFetched" : fetch_stats.get( "pagesFetched" , 0 ),
521 "maxPages" : args.max_pages,
522 "eventsProcessed" : event_count,
523 "recordsFetched" : len (all_records),
524 }
525 if window_clipped:
526 output[ "windowClippedNote" ] = (
527 "PARTIAL WINDOW: the requested start predates the 90-day CloudTrail Event History "
528 f "retention limit, so this covers only { start_time: % Y -% m -% d } onward (the requested "
529 f "start was { requested_start: % Y -% m -% d } ). For the older portion use Seller Reports / "
530 "SDDS — do NOT present this as covering the full requested range."
531 )
532 if truncated:
533 aggregate_types = { "summary" , "count" , "top_customers" }
534 output[ "completenessNote" ] = (
535 f "PARTIAL RESULT: the --max-pages cap ( { args.max_pages } ) was reached with more "
536 "CloudTrail events available, so this covers only the fetched sample of the window."
537 )
538 if args.query_type in aggregate_types:
539 output[ "aggregateWarning" ] = (
540 "Totals/rankings here are a LOWER BOUND over the fetched sample and are NOT "
541 "authoritative. Narrow the window (shorter range or add "
542 "--customer-id/--dimension/--status), raise --max-pages, or use Seller Reports "
543 "(billed amounts) for an authoritative total."
544 )
545 # A clipped 90-day window also makes an aggregate a lower bound for the requested range.
546 if window_clipped and args.query_type in { "summary" , "count" , "top_customers" }:
547 output.setdefault(
548 "aggregateWarning" ,
549 "Totals/rankings cover only the in-retention window (from 90 days ago), NOT the full "
550 "requested range — they are a LOWER BOUND. Use Seller Reports for the older portion." ,
551 )
552
553 json.dump(output, sys.stdout, indent = 2 , default = str )
554 print ()
555
556
557 if __name__ == "__main__" :
558 main()