@@ -40,8 +40,19 @@ import {
4040import {
4141 GetBatchEvaluationCommand ,
4242 ListBatchEvaluationsCommand ,
43+ StartBatchEvaluationCommand ,
44+ EvaluateCommand ,
4345 type ListBatchEvaluationsResponse ,
46+ type StartBatchEvaluationResponse ,
47+ type DataSourceConfig as DataPlaneDataSourceConfig ,
48+ type CloudWatchFilterConfig ,
4449} from "@aws-sdk/client-bedrock-agentcore" ;
50+ import {
51+ StartQueryCommand ,
52+ GetQueryResultsCommand ,
53+ type ResultField ,
54+ } from "@aws-sdk/client-cloudwatch-logs" ;
55+ import type { DocumentType } from "@smithy/types" ;
4556import { Transform } from "node:stream" ;
4657import { FileWriteError , InputValidationError , NetworkingError } from "../errors" ;
4758import type {
@@ -53,8 +64,14 @@ import type {
5364 CreateOnlineEvalInput ,
5465 GetBatchEvaluationResult ,
5566 LlmAsAJudgeUpdate ,
67+ OnDemandEvaluateInput ,
68+ OnDemandEvaluateResult ,
69+ OnDemandEvaluateScore ,
70+ StartBatchEvaluationInput ,
5671 UpdateOnlineEvalInput ,
5772} from "../handlers/eval/types" ;
73+ import type { SessionSourceValue , SessionWindow } from "../handlers/eval/sessionSource" ;
74+ import { toSessionFilter } from "../handlers/eval/sessionSource" ;
5875import { atomicWriteStream } from "../io" ;
5976import { isTerminalStatus , readEvaluationResults } from "./batchEvaluationResults" ;
6077import type { AwsClients , CoreFetch , CoreOptions } from "./types" ;
@@ -280,6 +297,114 @@ export class EvalClient implements CoreEvalClient {
280297 . send ( new ListBatchEvaluationsCommand ( { nextToken, maxResults } ) ) ;
281298 }
282299
300+ // startBatchEvaluation submits the async, service-side job. Core translates the
301+ // resolved SessionSourceValue into the dataSourceConfig union: the agent arm
302+ // resolves the harness/runtime id to a log group (reusing agentDataSource), the
303+ // online-eval arm points at a config ARN, and the raw arm passes JSON through.
304+ async startBatchEvaluation (
305+ input : StartBatchEvaluationInput ,
306+ options : CoreOptions ,
307+ ) : Promise < StartBatchEvaluationResponse > {
308+ const dataSourceConfig = await this . dataSourceConfigForSource ( input . source , options ) ;
309+ return this . clients . data ( toClientConfig ( options ) ) . send (
310+ new StartBatchEvaluationCommand ( {
311+ batchEvaluationName : input . name ,
312+ description : input . description ,
313+ evaluators : input . evaluatorIds . map ( ( evaluatorId ) => ( { evaluatorId } ) ) ,
314+ dataSourceConfig,
315+ evaluationMetadata : input . groundTruth ? { sessionMetadata : input . groundTruth } : undefined ,
316+ kmsKeyArn : input . kmsKeyArn ,
317+ } ) ,
318+ ) ;
319+ }
320+
321+ // dataSourceConfigForSource maps a resolved SessionSourceValue to the data-plane
322+ // dataSourceConfig union. The agent arm reuses the same runtime resolution +
323+ // log-group derivation the control-plane agentDataSource uses, then attaches the
324+ // session-id / time-range filters; the raw arm is returned verbatim.
325+ private async dataSourceConfigForSource (
326+ source : SessionSourceValue ,
327+ options : CoreOptions ,
328+ ) : Promise < DataPlaneDataSourceConfig > {
329+ if ( source . origin === "raw" ) return source . dataSourceConfig ;
330+
331+ const timeRange = toSessionFilter ( source . window ) ;
332+
333+ if ( source . origin === "online-eval" ) {
334+ return {
335+ onlineEvaluationConfigSource : {
336+ onlineEvaluationConfigArn : source . onlineEvaluationConfigId ,
337+ timeRange,
338+ } ,
339+ } ;
340+ }
341+
342+ const qualifier = source . endpoint ?? DEFAULT_ENDPOINT_QUALIFIER ;
343+ const { runtimeId, runtimeName } = await resolveAgentToRuntime (
344+ source . agent ,
345+ this . clients ,
346+ options ,
347+ ) ;
348+ const filterConfig : CloudWatchFilterConfig | undefined =
349+ source . sessionIds || timeRange ? { sessionIds : source . sessionIds , timeRange } : undefined ;
350+ return {
351+ cloudWatchLogs : {
352+ logGroupNames : [ runtimeLogGroup ( runtimeId , qualifier ) ] ,
353+ serviceNames : [ runtimeServiceName ( runtimeName , qualifier ) ] ,
354+ filterConfig,
355+ } ,
356+ } ;
357+ }
358+
359+ // evaluateOnDemand runs the synchronous, client-side path: it queries CloudWatch
360+ // for the target sessions' OTel spans itself, then calls the Evaluate API once
361+ // per (evaluator, session) with the collected spans. Ported from the old CLI's
362+ // fetchSessionSpans + runEvaluatorsOverSessions. No job is created.
363+ async evaluateOnDemand (
364+ input : OnDemandEvaluateInput ,
365+ options : CoreOptions ,
366+ ) : Promise < OnDemandEvaluateResult > {
367+ const qualifier = input . endpoint ?? DEFAULT_ENDPOINT_QUALIFIER ;
368+ const { runtimeId } = await resolveAgentToRuntime ( input . agent , this . clients , options ) ;
369+ const logGroup = runtimeLogGroup ( runtimeId , qualifier ) ;
370+ const logs = this . clients . logs ( { region : options . region } ) ;
371+
372+ const sessions = await fetchSessionSpans ( logs , {
373+ runtimeId,
374+ runtimeLogGroup : logGroup ,
375+ window : input . window ,
376+ sessionIds : input . sessionIds ,
377+ } ) ;
378+ if ( sessions . length === 0 ) {
379+ throw new InputValidationError (
380+ `No sessions with evaluable spans were found for agent "${ input . agent } ". ` +
381+ `Widen --lookback-days / the time window, or check --session-ids.` ,
382+ { meta : { agent : input . agent } } ,
383+ ) ;
384+ }
385+
386+ const data = this . clients . data ( toClientConfig ( options ) ) ;
387+ const scores : OnDemandEvaluateScore [ ] = [ ] ;
388+ for ( const evaluatorId of input . evaluatorIds ) {
389+ const results = [ ] ;
390+ for ( const session of sessions ) {
391+ const response = await data . send (
392+ new EvaluateCommand ( {
393+ evaluatorId,
394+ evaluationInput : { sessionSpans : session . spans } ,
395+ } ) ,
396+ ) ;
397+ results . push ( ...( response . evaluationResults ?? [ ] ) ) ;
398+ }
399+ const numeric = results . map ( ( r ) => r . value ) . filter ( ( v ) : v is number => typeof v === "number" ) ;
400+ const aggregateScore =
401+ numeric . length > 0 ? numeric . reduce ( ( sum , v ) => sum + v , 0 ) / numeric . length : 0 ;
402+ scores . push ( { evaluatorId, aggregateScore, results } ) ;
403+ }
404+
405+ return { sessionsEvaluated : sessions . length , scores } ;
406+ }
407+
283408 async createOnlineEvaluationConfig (
284409 input : CreateOnlineEvalInput ,
285410 options : CoreOptions ,
@@ -666,6 +791,179 @@ function runtimeLogGroup(runtimeId: string, endpoint: string): string {
666791 return `/aws/bedrock-agentcore/runtimes/${ runtimeId } -${ endpoint } ` ;
667792}
668793
794+ // --- on-demand client-side span collection (ported from the old CLI's
795+ // operations/eval/shared/span-collector.ts) ---
796+
797+ const SPANS_LOG_GROUP = "aws/spans" ;
798+
799+ // Instrumentation scopes / log records the Evaluate API understands. A runtime
800+ // log record with body.input/body.output carries the conversation turn text.
801+ const SUPPORTED_SCOPES = new Set ( [
802+ "strands.telemetry.tracer" ,
803+ "opentelemetry.instrumentation.langchain" ,
804+ "openinference.instrumentation.langchain" ,
805+ ] ) ;
806+
807+ type CollectedSession = { sessionId : string ; spans : DocumentType [ ] } ;
808+
809+ type FetchSpansOptions = {
810+ runtimeId : string ;
811+ runtimeLogGroup : string ;
812+ window ?: SessionWindow ;
813+ sessionIds ?: string [ ] ;
814+ } ;
815+
816+ // sanitizeQueryValue strips single quotes so an id can't break out of a CloudWatch
817+ // Insights query string literal.
818+ function sanitizeQueryValue ( value : string ) : string {
819+ return value . replace ( / ' / g, "" ) ;
820+ }
821+
822+ // runCwQuery runs a CloudWatch Logs Insights query and waits for completion,
823+ // returning [] if the log group does not exist yet.
824+ async function runCwQuery (
825+ logs : ReturnType < AwsClients [ "logs" ] > ,
826+ logGroupName : string ,
827+ queryString : string ,
828+ startTimeSec : number ,
829+ endTimeSec : number ,
830+ ) : Promise < ResultField [ ] [ ] > {
831+ let queryId : string | undefined ;
832+ try {
833+ const started = await logs . send (
834+ new StartQueryCommand ( {
835+ logGroupName,
836+ startTime : startTimeSec ,
837+ endTime : endTimeSec ,
838+ queryString,
839+ } ) ,
840+ ) ;
841+ queryId = started . queryId ;
842+ } catch ( error ) {
843+ const name = ( error as { name ?: string } ) ?. name ;
844+ if ( name === "ResourceNotFoundException" ) return [ ] ;
845+ throw error ;
846+ }
847+ if ( ! queryId ) return [ ] ;
848+
849+ for ( let i = 0 ; i < 60 ; i ++ ) {
850+ await new Promise ( ( resolve ) => setTimeout ( resolve , 1000 ) ) ;
851+ const res = await logs . send ( new GetQueryResultsCommand ( { queryId } ) ) ;
852+ const status = res . status ?? "Unknown" ;
853+ if ( status === "Failed" || status === "Cancelled" ) {
854+ throw new NetworkingError ( `CloudWatch query ${ status . toLowerCase ( ) } ` ) ;
855+ }
856+ if ( status === "Complete" ) return res . results ?? [ ] ;
857+ }
858+ throw new NetworkingError ( "CloudWatch query timed out after 60 seconds" ) ;
859+ }
860+
861+ // fetchSessionSpans queries the shared `aws/spans` log group (and the runtime's
862+ // own log group) for the agent's OTel spans, grouping them by session id. The
863+ // Evaluate API takes one session's spans per call. Time window and specific
864+ // session ids narrow the query. Ported from the old CLI's fetchSessionSpans.
865+ async function fetchSessionSpans (
866+ logs : ReturnType < AwsClients [ "logs" ] > ,
867+ opts : FetchSpansOptions ,
868+ ) : Promise < CollectedSession [ ] > {
869+ const filter = toSessionFilter ( opts . window ) ;
870+ const endTimeMs = filter ? + filter . endTime : Date . now ( ) ;
871+ // Default to a 30-day window when the caller gave no time filter, so the query
872+ // is bounded rather than scanning all retained logs.
873+ const startTimeMs = filter ? + filter . startTime : endTimeMs - 30 * 24 * 60 * 60 * 1000 ;
874+ const startTimeSec = Math . floor ( startTimeMs / 1000 ) ;
875+ const endTimeSec = Math . floor ( endTimeMs / 1000 ) ;
876+
877+ let spanQuery =
878+ `fields @message, attributes.session.id as sessionId, traceId\n` +
879+ ` | parse resource.attributes.cloud.resource_id "runtime/*/" as parsedAgentId\n` +
880+ ` | filter parsedAgentId = '${ sanitizeQueryValue ( opts . runtimeId ) } '\n` +
881+ ` | filter ispresent(scope.name) and ispresent(kind)` ;
882+ if ( opts . sessionIds && opts . sessionIds . length > 0 ) {
883+ const ids = opts . sessionIds . map ( ( s ) => `'${ sanitizeQueryValue ( s ) } '` ) . join ( ", " ) ;
884+ spanQuery += `\n | filter attributes.session.id in [${ ids } ]` ;
885+ }
886+ spanQuery += `\n | sort startTimeUnixNano asc\n | limit 10000` ;
887+
888+ const [ sharedRows , runtimeRows ] = await Promise . all ( [
889+ runCwQuery ( logs , SPANS_LOG_GROUP , spanQuery , startTimeSec , endTimeSec ) ,
890+ runCwQuery ( logs , opts . runtimeLogGroup , spanQuery , startTimeSec , endTimeSec ) ,
891+ ] ) ;
892+ const allSpanRows = [ ...sharedRows , ...runtimeRows ] ;
893+
894+ // Phase 1: group the OTel spans by session and collect their trace ids. Keep
895+ // every span doc — the Evaluate API needs full trace context, so we do NOT
896+ // filter these (only the runtime log records added in phase 2 are filtered).
897+ const sessionMap = new Map < string , DocumentType [ ] > ( ) ;
898+ const traceToSession = new Map < string , string > ( ) ;
899+ const traceIds = new Set < string > ( ) ;
900+ for ( const row of allSpanRows ) {
901+ const message = row . find ( ( f ) => f . field === "@message" ) ?. value ;
902+ const sessionId = row . find ( ( f ) => f . field === "sessionId" ) ?. value ?? "unknown" ;
903+ const traceId = row . find ( ( f ) => f . field === "traceId" ) ?. value ;
904+ if ( ! message ) continue ;
905+ let doc : Record < string , unknown > ;
906+ try {
907+ doc = JSON . parse ( message ) as Record < string , unknown > ;
908+ } catch {
909+ continue ;
910+ }
911+ if ( ! sessionMap . has ( sessionId ) ) sessionMap . set ( sessionId , [ ] ) ;
912+ sessionMap . get ( sessionId ) ! . push ( doc as DocumentType ) ;
913+ if ( traceId ) {
914+ traceIds . add ( traceId ) ;
915+ traceToSession . set ( traceId , sessionId ) ;
916+ }
917+ }
918+
919+ if ( sessionMap . size === 0 ) return [ ] ;
920+
921+ // Phase 2: the spans reference conversation turns whose text lives in the
922+ // runtime's own log records (body.input / body.output). Without them the
923+ // Evaluate API reports LogEventMissingException, so pull the log records for the
924+ // discovered trace ids and attach them to their session.
925+ if ( traceIds . size > 0 ) {
926+ const traceFilter = [ ...traceIds ] . map ( ( t ) => `'${ sanitizeQueryValue ( t ) } '` ) . join ( ", " ) ;
927+ const logRows = await runCwQuery (
928+ logs ,
929+ opts . runtimeLogGroup ,
930+ `fields @message, traceId\n` +
931+ ` | filter traceId in [${ traceFilter } ]\n` +
932+ ` | sort @timestamp asc\n | limit 10000` ,
933+ startTimeSec ,
934+ endTimeSec ,
935+ ) ;
936+ for ( const row of logRows ) {
937+ const message = row . find ( ( f ) => f . field === "@message" ) ?. value ;
938+ const traceId = row . find ( ( f ) => f . field === "traceId" ) ?. value ;
939+ if ( ! message ) continue ;
940+ let doc : Record < string , unknown > ;
941+ try {
942+ doc = JSON . parse ( message ) as Record < string , unknown > ;
943+ } catch {
944+ continue ;
945+ }
946+ if ( ! isRelevantForEval ( doc ) ) continue ;
947+ const sessionId = traceId ? ( traceToSession . get ( traceId ) ?? "unknown" ) : "unknown" ;
948+ if ( ! sessionMap . has ( sessionId ) ) sessionMap . set ( sessionId , [ ] ) ;
949+ sessionMap . get ( sessionId ) ! . push ( doc as DocumentType ) ;
950+ }
951+ }
952+
953+ return [ ...sessionMap ]
954+ . filter ( ( [ , spans ] ) => spans . length > 0 )
955+ . map ( ( [ sessionId , spans ] ) => ( { sessionId, spans } ) ) ;
956+ }
957+
958+ // isRelevantForEval keeps spans the Evaluate API can score: a supported
959+ // instrumentation scope, or a runtime log record carrying conversation turn text.
960+ function isRelevantForEval ( doc : Record < string , unknown > ) : boolean {
961+ const scopeName = ( doc . scope as Record < string , unknown > | undefined ) ?. name as string | undefined ;
962+ if ( scopeName && SUPPORTED_SCOPES . has ( scopeName ) ) return true ;
963+ const body = doc . body ;
964+ return ! ! body && typeof body === "object" && ( "input" in body || "output" in body ) ;
965+ }
966+
669967// runtimeServiceName derives the CloudWatch trace service name that scopes a
670968// CreateOnlineEvaluationConfig data source to one runtime endpoint's sessions:
671969// `{runtimeName}.{endpoint}`, keyed by the runtime *name* (verified against
0 commit comments