@@ -2523,6 +2523,8 @@ describe("sendJobConclusionSpan", () => {
25232523 "OTEL_SERVICE_NAME" ,
25242524 "GH_AW_EFFECTIVE_TOKENS" ,
25252525 "GH_AW_AIC" ,
2526+ "GH_AW_MAX_AI_CREDITS" ,
2527+ "GH_AW_AI_CREDITS_RATE_LIMIT_ERROR" ,
25262528 "GH_AW_INFO_VERSION" ,
25272529 "GH_AW_INFO_CLI_VERSION" ,
25282530 "GITHUB_AW_OTEL_TRACE_ID" ,
@@ -2645,6 +2647,7 @@ describe("sendJobConclusionSpan", () => {
26452647 process . env . INPUT_JOB_NAME = "agent" ;
26462648 process . env . GITHUB_AW_OTEL_TRACE_ID = "f" . repeat ( 32 ) ;
26472649 process . env . GITHUB_AW_OTEL_PARENT_SPAN_ID = "abcdef1234567890" ;
2650+ process . env . GH_AW_MAX_AI_CREDITS = "1000" ;
26482651
26492652 const startMs = 1_700_000_000_000 ;
26502653 const endMs = 1_700_000_005_000 ;
@@ -2677,6 +2680,14 @@ describe("sendJobConclusionSpan", () => {
26772680 expect ( conclusionSpan . parentSpanId ) . toBe ( "abcdef1234567890" ) ;
26782681 expect ( agentSpan . attributes ) . toContainEqual ( { key : "gh-aw.output.item_count" , value : { intValue : 2 } } ) ;
26792682 expect ( conclusionSpan . attributes ) . toContainEqual ( { key : "gh-aw.output.item_count" , value : { intValue : 2 } } ) ;
2683+ const agentKeys = agentSpan . attributes . map ( a => a . key ) ;
2684+ const conclusionKeys = conclusionSpan . attributes . map ( a => a . key ) ;
2685+ expect ( agentKeys ) . not . toContain ( "gh-aw.max_ai_credits" ) ;
2686+ expect ( agentKeys ) . not . toContain ( "gh-aw.max_ai_credits_exceeded" ) ;
2687+ expect ( agentKeys ) . not . toContain ( "gh-aw.ai_credits_rate_limit_error" ) ;
2688+ expect ( conclusionKeys ) . toContain ( "gh-aw.max_ai_credits" ) ;
2689+ expect ( conclusionKeys ) . toContain ( "gh-aw.max_ai_credits_exceeded" ) ;
2690+ expect ( conclusionKeys ) . toContain ( "gh-aw.ai_credits_rate_limit_error" ) ;
26802691 } ) ;
26812692
26822693 it ( "uses agent_cli_start_ms.txt as agent span start time when file is present" , async ( ) => {
@@ -3578,6 +3589,75 @@ describe("sendJobConclusionSpan", () => {
35783589 expect ( aicAttr . value . doubleValue ) . toBe ( 0.125 ) ;
35793590 } ) ;
35803591
3592+ it ( "emits gh-aw.max_ai_credits as a numeric conclusion-span attribute when available" , async ( ) => {
3593+ const mockFetch = vi . fn ( ) . mockResolvedValue ( { ok : true , status : 200 , statusText : "OK" } ) ;
3594+ vi . stubGlobal ( "fetch" , mockFetch ) ;
3595+
3596+ process . env . GH_AW_OTLP_ENDPOINTS = JSON . stringify ( [ { url : "https://traces.example.com" } ] ) ;
3597+ process . env . GH_AW_MAX_AI_CREDITS = "1000.5" ;
3598+
3599+ await sendJobConclusionSpan ( "gh-aw.job.conclusion" ) ;
3600+
3601+ const body = JSON . parse ( mockFetch . mock . calls [ 0 ] [ 1 ] . body ) ;
3602+ const span = body . resourceSpans [ 0 ] . scopeSpans [ 0 ] . spans [ 0 ] ;
3603+ const maxAICreditsAttr = span . attributes . find ( a => a . key === "gh-aw.max_ai_credits" ) ;
3604+ expect ( maxAICreditsAttr ) . toBeDefined ( ) ;
3605+ expect ( maxAICreditsAttr . value . doubleValue ) . toBe ( 1000.5 ) ;
3606+ } ) ;
3607+
3608+ it ( "emits AI credits boolean cap/rate-limit attributes on conclusion spans when detected" , async ( ) => {
3609+ const mockFetch = vi . fn ( ) . mockResolvedValue ( { ok : true , status : 200 , statusText : "OK" } ) ;
3610+ vi . stubGlobal ( "fetch" , mockFetch ) ;
3611+
3612+ process . env . GH_AW_OTLP_ENDPOINTS = JSON . stringify ( [ { url : "https://traces.example.com" } ] ) ;
3613+
3614+ const stdioContent = Buffer . from ( "CAPIError: 429 Maximum AI credits exceeded (1002.381900 / 1000)." , "utf8" ) ;
3615+ const stdioLogPath = "/tmp/gh-aw/agent-stdio.log" ;
3616+ const MOCK_FD = 42 ;
3617+ const existsSpy = vi . spyOn ( fs , "existsSync" ) . mockImplementation ( p => p === stdioLogPath ) ;
3618+ const statSpy = vi . spyOn ( fs , "statSync" ) . mockImplementation ( p => {
3619+ if ( p === stdioLogPath ) return /** @type {fs.Stats } */ { size : stdioContent . length } ;
3620+ throw Object . assign ( new Error ( "ENOENT" ) , { code : "ENOENT" } ) ;
3621+ } ) ;
3622+ const openSpy = vi . spyOn ( fs , "openSync" ) . mockReturnValue ( /** @type {number } */ MOCK_FD ) ;
3623+ const readSpy = vi . spyOn ( fs , "readSync" ) . mockImplementation ( ( _fd , buf ) => {
3624+ stdioContent . copy ( /** @type {Buffer } */ buf ) ;
3625+ return stdioContent . length ;
3626+ } ) ;
3627+ const closeSpy = vi . spyOn ( fs , "closeSync" ) . mockImplementation ( ( ) => { } ) ;
3628+
3629+ try {
3630+ await sendJobConclusionSpan ( "gh-aw.job.conclusion" ) ;
3631+ } finally {
3632+ existsSpy . mockRestore ( ) ;
3633+ statSpy . mockRestore ( ) ;
3634+ openSpy . mockRestore ( ) ;
3635+ readSpy . mockRestore ( ) ;
3636+ closeSpy . mockRestore ( ) ;
3637+ }
3638+
3639+ const body = JSON . parse ( mockFetch . mock . calls [ 0 ] [ 1 ] . body ) ;
3640+ const span = body . resourceSpans [ 0 ] . scopeSpans [ 0 ] . spans [ 0 ] ;
3641+ const attrs = Object . fromEntries ( span . attributes . map ( a => [ a . key , a . value . boolValue ?? a . value . doubleValue ?? a . value . intValue ?? a . value . stringValue ] ) ) ;
3642+ expect ( attrs [ "gh-aw.max_ai_credits_exceeded" ] ) . toBe ( true ) ;
3643+ expect ( attrs [ "gh-aw.ai_credits_rate_limit_error" ] ) . toBe ( true ) ;
3644+ } ) ;
3645+
3646+ it ( "does not emit gh-aw.max_ai_credits when max AI credits is missing or invalid" , async ( ) => {
3647+ const mockFetch = vi . fn ( ) . mockResolvedValue ( { ok : true , status : 200 , statusText : "OK" } ) ;
3648+ vi . stubGlobal ( "fetch" , mockFetch ) ;
3649+
3650+ process . env . GH_AW_OTLP_ENDPOINTS = JSON . stringify ( [ { url : "https://traces.example.com" } ] ) ;
3651+ process . env . GH_AW_MAX_AI_CREDITS = "not-a-number" ;
3652+
3653+ await sendJobConclusionSpan ( "gh-aw.job.conclusion" ) ;
3654+
3655+ const body = JSON . parse ( mockFetch . mock . calls [ 0 ] [ 1 ] . body ) ;
3656+ const span = body . resourceSpans [ 0 ] . scopeSpans [ 0 ] . spans [ 0 ] ;
3657+ const keys = span . attributes . map ( a => a . key ) ;
3658+ expect ( keys ) . not . toContain ( "gh-aw.max_ai_credits" ) ;
3659+ } ) ;
3660+
35813661 it ( "emits dashboard metrics and aliases on the conclusion span" , async ( ) => {
35823662 const mockFetch = vi . fn ( ) . mockResolvedValue ( { ok : true , status : 200 , statusText : "OK" } ) ;
35833663 vi . stubGlobal ( "fetch" , mockFetch ) ;
0 commit comments