@@ -2362,6 +2362,36 @@ describe("sendJobConclusionSpan", () => {
23622362 expect ( attrs [ "gen_ai.workflow.name" ] ) . toBe ( "otel-advisor" ) ;
23632363 } ) ;
23642364
2365+ it ( "does not duplicate gen_ai.request.model on the agent span" , async ( ) => {
2366+ const mockFetch = vi . fn ( ) . mockResolvedValue ( { ok : true , status : 200 , statusText : "OK" } ) ;
2367+ vi . stubGlobal ( "fetch" , mockFetch ) ;
2368+
2369+ process . env . GH_AW_OTLP_ENDPOINTS = JSON . stringify ( [ { url : "https://traces.example.com" } ] ) ;
2370+ process . env . INPUT_JOB_NAME = "agent" ;
2371+
2372+ const startMs = 1_700_000_000_000 ;
2373+ const endMs = 1_700_000_005_000 ;
2374+ const statSpy = vi . spyOn ( fs , "statSync" ) . mockReturnValue ( /** @type {Partial<fs.Stats> } */ { mtimeMs : endMs } ) ;
2375+ const readFileSpy = vi . spyOn ( fs , "readFileSync" ) . mockImplementation ( filePath => {
2376+ if ( filePath === "/tmp/gh-aw/aw_info.json" ) {
2377+ return JSON . stringify ( { model : "gpt-4o" , engine_id : "codex" } ) ;
2378+ }
2379+ throw Object . assign ( new Error ( "ENOENT" ) , { code : "ENOENT" } ) ;
2380+ } ) ;
2381+
2382+ await sendJobConclusionSpan ( "gh-aw.agent.conclusion" , { startMs } ) ;
2383+
2384+ statSpy . mockRestore ( ) ;
2385+ readFileSpy . mockRestore ( ) ;
2386+
2387+ const agentBody = JSON . parse ( mockFetch . mock . calls [ 0 ] [ 1 ] . body ) ;
2388+ const agentSpan = agentBody . resourceSpans [ 0 ] . scopeSpans [ 0 ] . spans [ 0 ] ;
2389+ const modelKeys = agentSpan . attributes . filter ( a => a . key === "gen_ai.request.model" ) ;
2390+ // gen_ai.request.model must appear exactly once — no duplicate from a second push
2391+ expect ( modelKeys ) . toHaveLength ( 1 ) ;
2392+ expect ( modelKeys [ 0 ] . value . stringValue ) . toBe ( "gpt-4o" ) ;
2393+ } ) ;
2394+
23652395 it ( "omits gen_ai.request.model, gen_ai.system, gh-aw.engine and gen_ai.workflow.name from the agent span when model, engine_id and workflow_name are absent" , async ( ) => {
23662396 const mockFetch = vi . fn ( ) . mockResolvedValue ( { ok : true , status : 200 , statusText : "OK" } ) ;
23672397 vi . stubGlobal ( "fetch" , mockFetch ) ;
@@ -3705,7 +3735,7 @@ describe("sendJobConclusionSpan", () => {
37053735 statSpy . mockRestore ( ) ;
37063736 } ) ;
37073737
3708- it ( "includes all four gen_ai token breakdown attributes on the conclusion span when agent_usage.json is present " , async ( ) => {
3738+ it ( "omits gen_ai token breakdown attributes from conclusion span when agent sub-span is emitted (token attrs go to agent span only) " , async ( ) => {
37093739 const mockFetch = vi . fn ( ) . mockResolvedValue ( { ok : true , status : 200 , statusText : "OK" } ) ;
37103740 vi . stubGlobal ( "fetch" , mockFetch ) ;
37113741
@@ -3724,11 +3754,14 @@ describe("sendJobConclusionSpan", () => {
37243754 // mockFetch.mock.calls[0] is the agent span, [1] is the conclusion span
37253755 const conclusionBody = JSON . parse ( mockFetch . mock . calls [ 1 ] [ 1 ] . body ) ;
37263756 const conclusionSpan = conclusionBody . resourceSpans [ 0 ] . scopeSpans [ 0 ] . spans [ 0 ] ;
3727- const attrs = Object . fromEntries ( conclusionSpan . attributes . map ( a => [ a . key , a . value . intValue ?? a . value . stringValue ] ) ) ;
3728- expect ( attrs [ "gen_ai.usage.input_tokens" ] ) . toBe ( 48200 ) ;
3729- expect ( attrs [ "gen_ai.usage.output_tokens" ] ) . toBe ( 1350 ) ;
3730- expect ( attrs [ "gen_ai.usage.cache_read.input_tokens" ] ) . toBe ( 41000 ) ;
3731- expect ( attrs [ "gen_ai.usage.cache_creation.input_tokens" ] ) . toBe ( 3100 ) ;
3757+ const conclusionKeys = conclusionSpan . attributes . map ( a => a . key ) ;
3758+ // Token-usage attributes must not appear on the conclusion span when an agent
3759+ // sub-span is emitted; they live exclusively on the agent span to prevent
3760+ // double-counting in backends that sum gen_ai.usage.* across all spans.
3761+ expect ( conclusionKeys ) . not . toContain ( "gen_ai.usage.input_tokens" ) ;
3762+ expect ( conclusionKeys ) . not . toContain ( "gen_ai.usage.output_tokens" ) ;
3763+ expect ( conclusionKeys ) . not . toContain ( "gen_ai.usage.cache_read.input_tokens" ) ;
3764+ expect ( conclusionKeys ) . not . toContain ( "gen_ai.usage.cache_creation.input_tokens" ) ;
37323765 } ) ;
37333766
37343767 it ( "includes gen_ai token breakdown on conclusion span even when no agent sub-span is emitted" , async ( ) => {
@@ -3779,7 +3812,7 @@ describe("sendJobConclusionSpan", () => {
37793812 expect ( keys ) . not . toContain ( "gen_ai.usage.cache_creation.input_tokens" ) ;
37803813 } ) ;
37813814
3782- it ( "omits a gen_ai token attribute from the conclusion span when its value is zero " , async ( ) => {
3815+ it ( "omits all gen_ai token breakdown attributes from conclusion span regardless of zero-values when agent sub-span is emitted " , async ( ) => {
37833816 const mockFetch = vi . fn ( ) . mockResolvedValue ( { ok : true , status : 200 , statusText : "OK" } ) ;
37843817 vi . stubGlobal ( "fetch" , mockFetch ) ;
37853818
@@ -3798,11 +3831,11 @@ describe("sendJobConclusionSpan", () => {
37983831 // mockFetch.mock.calls[0] is the agent span, [1] is the conclusion span
37993832 const conclusionBody = JSON . parse ( mockFetch . mock . calls [ 1 ] [ 1 ] . body ) ;
38003833 const conclusionSpan = conclusionBody . resourceSpans [ 0 ] . scopeSpans [ 0 ] . spans [ 0 ] ;
3801- const attrs = Object . fromEntries ( conclusionSpan . attributes . map ( a => [ a . key , a . value . intValue ?? a . value . stringValue ] ) ) ;
3802- expect ( attrs [ "gen_ai.usage.input_tokens" ] ) . toBe ( 1000 ) ;
3803- expect ( attrs [ "gen_ai.usage.cache_read.input_tokens" ] ) . toBe ( 500 ) ;
38043834 const keys = conclusionSpan . attributes . map ( a => a . key ) ;
3835+ // No token-usage attributes on conclusion span when an agent sub-span is emitted.
3836+ expect ( keys ) . not . toContain ( "gen_ai.usage.input_tokens" ) ;
38053837 expect ( keys ) . not . toContain ( "gen_ai.usage.output_tokens" ) ;
3838+ expect ( keys ) . not . toContain ( "gen_ai.usage.cache_read.input_tokens" ) ;
38063839 expect ( keys ) . not . toContain ( "gen_ai.usage.cache_creation.input_tokens" ) ;
38073840 } ) ;
38083841 } ) ;
0 commit comments