Setting the file. One moment.
Capture Dataset From Traces · AWS Observability · aws/agent-toolkit-for-aws · Skills Docs
ContentsBack to the top of the page 69
Creating Amazon Aurora Db Cluster With Instances
92
Routing Traffic With Route53 And CloudFront
Resilience Program Design
Debugging Lambda Timeouts
— line 301
This file
Number 37.40
Position 40 of 67
Type Python
Size 18 KB
Lines 438 scripts/cloudwatch-omni/ capture_dataset_from_traces.py
Python · 438 lines · 18 KB
15 Space and cloudwatch:StartQuery / cloudwatch:GetQueryResults permissions.
16
17 Examples:
18 python capture_dataset_from_traces.py --mode create --dataset-name checkout_regressions \
19 --trace-ids 6a7f...,6a80... --region us-east-1
20 python capture_dataset_from_traces.py --mode add --dataset-id my_ds-AbC123 \
21 --trace-ids 6a81...
22 """
23 from __future__ import annotations
24
25 import argparse
26 import json
27 import re
28 import sys
29 import time
30 from typing import NoReturn
31 from uuid import uuid4
32
33 import boto3
34 from botocore.exceptions import BotoCoreError, ClientError
35
36 # String-valued attribute keys only (COALESCE order). The OTel GenAI-semconv
37 # `gen_ai.input.messages`/`gen_ai.output.messages` are structured ARRAYS, not strings,
38 # so they're intentionally excluded — treating them as a plain string would either be
39 # dead (non-str skipped) or inject a raw serialized-array blob as the turn text.
40 INPUT_KEYS = ( "input.value" , "gen_ai.input_value" )
41 OUTPUT_KEYS = ( "output.value" , "gen_ai.output_value" )
42 SCHEMA_TYPE = "AGENTCORE_EVALUATION_PREDEFINED_V1"
43 MAX_TRACES = 25 # single write batch; excess is reported, never silently dropped
44 SPAN_ROW_LIMIT = 1000 # fetch LIMIT+1 (1001) so an exactly-full result is detectable as truncated
45 # Service limits (bedrock-agentcore-control datasets): each example must serialize to <= 1 MB
46 # (a 400 on the whole batch otherwise), and a dataset holds at most 1000 examples in total
47 # (a write past that is a 402 ServiceQuotaExceededException).
48 MAX_EXAMPLE_BYTES = 1_000_000
49 MAX_DATASET_EXAMPLES = 1000
50 QUERY_POLL_ATTEMPTS = (
51 150 # × 2s = up to 5 min for the (heavy: 25 traces / 30-day) query to complete
52 )
53 # Trace ids are hex; validate before interpolating into the Omni SQL query string.
54 _TRACE_ID_RE = re.compile( r " ^[ 0-9a-fA-F ] {1,64} $ " )
55
56
57 def _die (payload) -> NoReturn:
58 """Print an error receipt to stdout (so the agent sees it) and exit non-zero."""
59 print (json.dumps(payload))
60 sys.exit( 1 )
61
62
63 # ---- pure conversion (same mapping as references/dataset-from-traces.md) ----
64 def _attr_text (span, keys):
65 attrs = (span or {}).get( "attributes" ) or {}
66 for k in keys:
67 v = attrs.get(k)
68 if isinstance (v, str ) and v.strip():
69 return v.strip()
70 return None
71
72
73 def _scan (spans, keys, reverse = False ):
74 for s in reversed (spans) if reverse else spans:
75 t = _attr_text(s, keys)
76 if t:
77 return t
78 return None
79
80
81 def _root (spans):
82 return next ((s for s in spans if not s.get( "parentSpanId" )), spans[ 0 ] if spans else None )
83
84
85 def _collapse_adjacent (items):
86 out: list = []
87 for x in items:
88 if not out or out[ - 1 ] != x:
89 out.append(x)
90 return out
91
92
93 def _span_kind (s):
94 a = s.get( "attributes" ) or {}
95 return a.get( "openinference.span.kind" ) or a.get( "aws.genai.span_kind" )
96
97
98 def to_example (trace_id, spans, partial = False ):
99 spans = sorted (spans, key =lambda s: s.get( "ts" ) or "" ) # start-time order
100 root = _root(spans)
101 turn = { "input" : _attr_text(root, INPUT_KEYS ) or _scan(spans, INPUT_KEYS ) or "" }
102 out = _attr_text(root, OUTPUT_KEYS ) or _scan(spans, OUTPUT_KEYS , reverse = True )
103 if out:
104 turn[ "expected_response" ] = out
105 session_id = next (
106 (
107 s[ "attributes" ].get( "session.id" )
108 for s in spans
109 if (s.get( "attributes" ) or {}).get( "session.id" )
110 ),
111 None ,
112 )
113 metadata = { "sourceTraceId" : trace_id}
114 if session_id:
115 metadata[ "sessionId" ] = session_id
116 if partial:
117 metadata[ "partial" ] = True
118 example = { "scenario_id" : trace_id, "turns" : [turn], "metadata" : metadata}
119 tools = [
120 (s.get( "attributes" ) or {}).get( "tool.name" )
121 for s in spans
122 if _span_kind(s) == "TOOL" and (s.get( "attributes" ) or {}).get( "tool.name" )
123 ]
124 trajectory = _collapse_adjacent(tools)
125 if trajectory:
126 example[ "expected_trajectory" ] = trajectory
127 return example
128
129
130 def invalid (example):
131 """Return an error string if the example fails the PREDEFINED gate, else None."""
132 turns = example.get( "turns" )
133 if not turns:
134 return "no turns"
135 val = turns[ 0 ].get( "input" )
136 if not (( isinstance (val, str ) and val.strip()) or ( isinstance (val, dict ) and val)):
137 return "turn 1 missing a non-empty input"
138 # The service rejects the WHOLE batch if any single example exceeds 1 MB serialized.
139 size = len (json.dumps(example, default = str ).encode( "utf-8" ))
140 if size > MAX_EXAMPLE_BYTES :
141 return "example serializes to %d bytes; the service caps one example at %d bytes" % (size, MAX_EXAMPLE_BYTES )
142 return None
143
144
145 # ---- span fetch via CloudWatch Omni SQL (traces.default) ----
146 def fetch_spans_by_trace (omni, trace_ids, window_days):
147 """Fetch spans for the trace ids via Omni SQL, sorted by (traceId, startTimeUnixNano).
148
149 Returns (by_trace, truncated, boundary). Because the query sorts by traceId asc, a
150 row-cap hit only ever cuts the *last* returned trace mid-way — every lower-sorted trace
151 is complete, and every higher-sorted (requested) trace was never reached. So on
152 truncation only `boundary` (the last returned trace id) is partial; the caller flags the
153 unreached traces distinctly from genuine misses.
154 """
155 quoted = ", " .join( "' %s '" % t for t in trace_ids) # ids are validated hex — safe to interpolate
156 sql = (
157 "SELECT traceId, spanId, parentSpanId, startTimeUnixNano, "
158 "attributes['openinference.span.kind'] AS oi_span_kind, "
159 "attributes['aws.genai.span_kind'] AS aws_genai_span_kind, "
160 "attributes['tool.name'] AS tool_name, "
161 "attributes['input.value'] AS input_value, "
162 "attributes['output.value'] AS output_value, "
163 # String-valued gen_ai.* fallbacks — the *.messages array keys are omitted
164 # (they are not plain strings; the COALESCE in _attr_text skips non-str values).
165 "attributes['gen_ai.input_value'] AS gen_ai_input_value, "
166 "attributes['gen_ai.output_value'] AS gen_ai_output_value, "
167 "attributes['session.id'] AS session_id_attr "
168 "FROM \" traces.default \" "
169 "WHERE traceId IN ( %s ) "
170 "AND \" @timestamp \" BETWEEN NOW() - INTERVAL ' %d days' AND NOW() "
171 "ORDER BY traceId ASC, startTimeUnixNano ASC "
172 # fetch one past the cap so an exactly-full result isn't mistaken for truncation
173 "LIMIT %d "
174 ) % (quoted, window_days, SPAN_ROW_LIMIT + 1 )
175
176 rows: list = []
177 truncated = False
178 session_id = None
179 try :
180 try :
181 session_id = omni.start_telemetry_query_session(
182 sessionName = "capture-dataset"
183 )[ "sessionId" ]
184 except (BotoCoreError, ClientError) as e:
185 _die({ "error" : "Omni start_telemetry_query_session failed: %s " % e})
186
187 try :
188 qid = omni.start_telemetry_query(
189 sessionId = session_id, queryString = sql
190 )[ "queryId" ]
191 except (BotoCoreError, ClientError) as e:
192 _die({ "error" : "Omni start_telemetry_query failed: %s " % e})
193
194 # Phase 1: poll for completion (status only; rows are not stable until Complete)
195 status = None
196 for _ in range ( QUERY_POLL_ATTEMPTS ):
197 time.sleep( 2 )
198 try :
199 poll = omni.get_telemetry_query_results( queryId = qid, maxResults = 1 )
200 except (BotoCoreError, ClientError) as e:
201 _die({ "error" : "Omni get_telemetry_query_results failed: %s " % e})
202 status = poll.get( "status" )
203 if status in ( "Complete" , "Failed" , "Cancelled" ):
204 break
205
206 if status != "Complete" :
207 _die(
208 {
209 "error" : "Omni SQL query did not complete (status= %s ); "
210 "reduce --trace-ids or narrow --window-days" % status
211 }
212 )
213
214 # Phase 2: paginate while session is still open, accumulating up to SPAN_ROW_LIMIT+1
215 # rows. The SQL uses LIMIT SPAN_ROW_LIMIT+1 (1001) so if the LIMIT was hit the 1001st
216 # row exists; once accumulated rows exceed SPAN_ROW_LIMIT we know it was hit. Paginating
217 # rather than trusting a single page handles short pages (fewer than maxResults rows
218 # returned with a nextToken) which are valid for any AWS paginated API.
219 next_token = None
220 while len (rows) <= SPAN_ROW_LIMIT :
221 try :
222 kwargs: dict = { "queryId" : qid, "maxResults" : 1000 }
223 if next_token:
224 kwargs[ "nextToken" ] = next_token
225 page = omni.get_telemetry_query_results( ** kwargs)
226 except (BotoCoreError, ClientError) as e:
227 _die({ "error" : "Omni get_telemetry_query_results (fetch) failed: %s " % e})
228 rows.extend(page.get( "rows" , []))
229 next_token = page.get( "nextToken" )
230 if not next_token:
231 break
232 finally :
233 if session_id:
234 try :
235 omni.stop_telemetry_query_session( sessionId = session_id)
236 except Exception :
237 pass
238
239 truncated = len (rows) > SPAN_ROW_LIMIT
240 if truncated:
241 rows = rows[: SPAN_ROW_LIMIT ] # drop the probe row before building spans
242
243 by_trace: dict = {}
244 order: list = []
245 # rows is a list of dicts: column alias → string value
246 _ATTR_COLS = [
247 ( "openinference.span.kind" , "oi_span_kind" ),
248 ( "aws.genai.span_kind" , "aws_genai_span_kind" ),
249 ( "tool.name" , "tool_name" ),
250 ( "input.value" , "input_value" ),
251 ( "output.value" , "output_value" ),
252 ( "gen_ai.input_value" , "gen_ai_input_value" ),
253 ( "gen_ai.output_value" , "gen_ai_output_value" ),
254 ( "session.id" , "session_id_attr" ),
255 ]
256 for row in rows:
257 tid = row.get( "traceId" )
258 if not tid:
259 continue
260 if tid not in by_trace:
261 order.append(tid)
262 attrs = {attr_key: row[col] for attr_key, col in _ATTR_COLS if row.get(col)}
263 by_trace.setdefault(tid, []).append(
264 {
265 "spanId" : row.get( "spanId" ),
266 "parentSpanId" : row.get( "parentSpanId" ) or None ,
267 "ts" : row.get( "startTimeUnixNano" ),
268 "attributes" : attrs,
269 }
270 )
271 boundary = order[ - 1 ] if (truncated and order) else None # the only possibly-cut trace
272 return by_trace, truncated, boundary
273
274
275 def _resolve_dataset_id (ctl, id_or_name):
276 token = None
277 while True :
278 try :
279 resp = ctl.list_datasets( ** ({ "nextToken" : token} if token else {}))
280 except (BotoCoreError, ClientError) as e:
281 _die({ "error" : "list_datasets failed: %s " % e})
282 for d in resp.get( "datasets" , []):
283 if d.get( "datasetId" ) == id_or_name or d.get( "datasetName" ) == id_or_name:
284 return d.get( "datasetId" )
285 token = resp.get( "nextToken" )
286 if not token:
287 _die({ "error" : "dataset %r not found" % id_or_name})
288
289
290 def _count_examples (ctl, dataset_id):
291 """Current example count of the dataset DRAFT (get_dataset.exampleCount), or None if unknown.
292 Best-effort: a failed lookup must not block the write -- the service enforces the cap anyway."""
293 try :
294 resp = ctl.get_dataset( datasetId = dataset_id)
295 except (BotoCoreError, ClientError):
296 return None
297 count = resp.get( "exampleCount" )
298 return count if isinstance (count, int ) else None
299
300
301 def main ():
302 ap = argparse.ArgumentParser(
303 description = "Build an AgentCore eval dataset from CloudWatch Omni traces."
304 )
305 ap.add_argument( "--trace-ids" , required = True , help = "comma-separated trace ids" )
306 ap.add_argument( "--mode" , choices = [ "create" , "add" ], default = "create" )
307 ap.add_argument( "--dataset-name" , help = "name for the new dataset (mode create)" )
308 ap.add_argument( "--dataset-id" , help = "existing dataset id or name (mode add)" )
309 ap.add_argument( "--description" )
310 ap.add_argument( "--region" , default = "us-east-1" )
311 ap.add_argument( "--window-days" , type = int , default = 30 , help = "trace lookback window" )
312 args = ap.parse_args()
313
314 if args.mode == "create" and not args.dataset_name:
315 _die({ "error" : "--dataset-name is required for --mode create" })
316 if args.mode == "add" and not (args.dataset_id or args.dataset_name):
317 _die({ "error" : "--dataset-id or --dataset-name is required for --mode add" })
318
319 seen, uniq, bad = set (), [], []
320 # Normalize to lowercase once: span trace ids are stored lowercase, the filter is an exact
321 # string match, and the boundary comparison relies on the query's case-sensitive `sort`.
322 for t in (x.strip().lower() for x in args.trace_ids.split( "," )):
323 if not t or t in seen:
324 continue
325 if not _TRACE_ID_RE .match(t):
326 bad.append(t)
327 continue
328 seen.add(t)
329 uniq.append(t)
330 if bad:
331 _die({ "error" : "invalid trace id(s) (expected hex): %s " % ", " .join(bad[: 5 ])})
332 if not uniq:
333 _die({ "error" : "no valid trace ids provided" })
334 notes = []
335 if len (uniq) > MAX_TRACES :
336 notes.append(
337 "captured the first %d of %d traces; run again for the rest" % ( MAX_TRACES , len (uniq))
338 )
339 uniq = uniq[: MAX_TRACES ]
340
341 session = boto3.Session( region_name = args.region)
342 omni = session.client( "cloudwatchomni" )
343 ctl = session.client( "bedrock-agentcore-control" )
344
345 by_trace, truncated, boundary = fetch_spans_by_trace(omni, uniq, args.window_days)
346 if truncated:
347 notes.append(
348 "Omni SQL query hit the %d -row cap; only fully-fetched traces were captured. The boundary "
349 "trace is flagged metadata.partial=true; any trace reported 'row cap reached before this "
350 "trace' was not fetched — reduce --trace-ids or narrow --window-days and re-run for those."
351 % SPAN_ROW_LIMIT
352 )
353 examples, conversion_errors = [], []
354 for tid in uniq:
355 spans = by_trace.get(tid) or []
356 if not spans:
357 # On truncation the query stops at `boundary` (traceId asc), so a requested id that
358 # sorts after it was never reached — report that distinctly from a genuine miss.
359 if (
360 truncated and boundary is not None and tid > boundary
361 ): # both lowercase; matches `sort traceId asc`
362 conversion_errors.append(
363 {
364 "traceId" : tid,
365 "error" : "row cap ( %d ) reached before this trace; not fetched"
366 % SPAN_ROW_LIMIT ,
367 }
368 )
369 else :
370 conversion_errors.append({ "traceId" : tid, "error" : "no spans found in window" })
371 continue
372 # Only the boundary trace can be mid-cut; every lower-sorted trace is complete.
373 partial = truncated and boundary is not None and tid == boundary
374 example = to_example(tid, spans, partial = partial)
375 err = invalid(example)
376 if err:
377 conversion_errors.append({ "traceId" : tid, "error" : err})
378 continue
379 examples.append(example)
380
381 if not examples:
382 _die({ "error" : "no examples could be built" , "conversionErrors" : conversion_errors})
383
384 source = { "inlineExamples" : { "examples" : examples}}
385 if args.mode == "create" :
386 kwargs = {
387 "datasetName" : args.dataset_name,
388 "schemaType" : SCHEMA_TYPE ,
389 "source" : source,
390 "clientToken" : str (uuid4()),
391 }
392 if args.description:
393 kwargs[ "description" ] = args.description
394 try :
395 resp = ctl.create_dataset( ** kwargs)
396 except (BotoCoreError, ClientError) as e:
397 _die({ "error" : "create_dataset failed: %s " % e, "conversionErrors" : conversion_errors})
398 receipt = {
399 "mode" : "create" ,
400 "datasetId" : resp.get( "datasetId" ),
401 "status" : resp.get( "status" ),
402 "examplesWritten" : len (examples),
403 }
404 else :
405 dsid = _resolve_dataset_id(ctl, args.dataset_id or args.dataset_name)
406 existing = _count_examples(ctl, dsid)
407 if existing is not None and existing + len (examples) > MAX_DATASET_EXAMPLES :
408 _die({
409 "error" : "dataset %s already holds %d examples; adding %d would exceed the service cap of %d "
410 "examples per dataset (the write would fail with 402 ServiceQuotaExceededException). "
411 "Create a new dataset (--mode create) for the remainder." % (dsid, existing, len (examples), MAX_DATASET_EXAMPLES ),
412 "conversionErrors" : conversion_errors,
413 })
414 try :
415 resp = ctl.add_dataset_examples( datasetId = dsid, source = source, clientToken = str (uuid4()))
416 except (BotoCoreError, ClientError) as e:
417 _die(
418 {
419 "error" : "add_dataset_examples failed: %s " % e,
420 "conversionErrors" : conversion_errors,
421 }
422 )
423 example_ids = resp.get( "exampleIds" ) or []
424 receipt = {
425 "mode" : "add" ,
426 "datasetId" : dsid,
427 "examplesWritten" : len (example_ids) or len (examples),
428 "exampleIds" : example_ids,
429 }
430 if conversion_errors:
431 receipt[ "conversionErrors" ] = conversion_errors
432 if notes:
433 receipt[ "notes" ] = notes
434 print (json.dumps(receipt, default = str , indent = 2 ))
435
436
437 if __name__ == "__main__" :
438 main()