From 7527975fdb1910b1bbdaa7a63b7040818588c849 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Apr 2026 23:56:28 +0000 Subject: [PATCH 1/2] Initial plan From 032f36110fdeb21f26d0a9bdc38bcd91ea603a46 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Apr 2026 00:05:40 +0000 Subject: [PATCH 2/2] feat: add trigger item context attributes to all OTel spans Add gh-aw.trigger.item_type, gh-aw.trigger.item_number, and gh-aw.trigger.label span attributes to both sendJobSetupSpan and sendJobConclusionSpan. These are read from awInfo.context in aw_info.json, which is written by buildAwContext() and contains the item_type, item_number, and trigger_label fields resolved from the GitHub event payload. Fixes # Agent-Logs-Url: https://github.com/github/gh-aw/sessions/add39553-beaa-4834-b682-cb9c26b4f0b9 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/send_otlp_span.cjs | 26 +++- actions/setup/js/send_otlp_span.test.cjs | 150 +++++++++++++++++++++++ 2 files changed, 173 insertions(+), 3 deletions(-) diff --git a/actions/setup/js/send_otlp_span.cjs b/actions/setup/js/send_otlp_span.cjs index e3ec3477ec6..47875487556 100644 --- a/actions/setup/js/send_otlp_span.cjs +++ b/actions/setup/js/send_otlp_span.cjs @@ -434,9 +434,12 @@ function isValidSpanId(id) { * * Runtime files read (optional): * - `/tmp/gh-aw/aw_info.json` – when present, `context.otel_trace_id` is used as a fallback - * trace ID so that dispatched child workflows share the parent's OTLP trace; and + * trace ID so that dispatched child workflows share the parent's OTLP trace; * `context.otel_parent_span_id` is used as the parent span ID so the child's setup span - * is properly nested under the parent's setup span in the trace hierarchy + * is properly nested under the parent's setup span in the trace hierarchy; and + * `context.item_type`, `context.item_number`, and `context.trigger_label` are emitted as + * `gh-aw.trigger.item_type`, `gh-aw.trigger.item_number`, and `gh-aw.trigger.label` + * attributes so every span can be linked back to the GitHub item that triggered the workflow * * @param {SendJobSetupSpanOptions} [options] * @returns {Promise<{ traceId: string, spanId: string }>} The trace and span IDs used. @@ -469,6 +472,9 @@ async function sendJobSetupSpan(options = {}) { const rawContextParentSpanId = typeof awInfo.context?.otel_parent_span_id === "string" ? awInfo.context.otel_parent_span_id.trim().toLowerCase() : ""; const contextParentSpanId = isValidSpanId(rawContextParentSpanId) ? rawContextParentSpanId : ""; const staged = awInfo.staged === true || process.env.GH_AW_INFO_STAGED === "true"; + const itemType = typeof awInfo.context?.item_type === "string" ? awInfo.context.item_type : ""; + const itemNumber = typeof awInfo.context?.item_number === "string" ? awInfo.context.item_number : ""; + const triggerLabel = typeof awInfo.context?.trigger_label === "string" ? awInfo.context.trigger_label : ""; const traceId = optionsTraceId || inputTraceId || contextTraceId || generateTraceId(); @@ -520,6 +526,9 @@ async function sendJobSetupSpan(options = {}) { attributes.push(buildAttr("gh-aw.deployment.state", deploymentStateSetup)); } attributes.push(buildAttr("gh-aw.staged", staged)); + if (itemType) attributes.push(buildAttr("gh-aw.trigger.item_type", itemType)); + if (itemNumber) attributes.push(buildAttr("gh-aw.trigger.item_number", itemNumber)); + if (triggerLabel) attributes.push(buildAttr("gh-aw.trigger.label", triggerLabel)); const resourceAttributes = [buildAttr("github.repository", repository), buildAttr("github.run_id", runId)]; if (repository && runId) { @@ -662,7 +671,12 @@ function readLastRateLimitEntry() { * - `GITHUB_REPOSITORY` – `owner/repo` string * * Runtime files read: - * - `/tmp/gh-aw/aw_info.json` – workflow/engine metadata written by the agent job + * - `/tmp/gh-aw/aw_info.json` – workflow/engine metadata written by the agent job; + * `context.item_type`, `context.item_number`, and + * `context.trigger_label` are emitted as + * `gh-aw.trigger.item_type`, `gh-aw.trigger.item_number`, + * and `gh-aw.trigger.label` attributes so every span can + * be linked back to the GitHub item that triggered the workflow * - `/tmp/gh-aw/agent_usage.json` – per-type token breakdown written by parse_token_usage.cjs; * provides `input_tokens`, `output_tokens`, * `cache_read_tokens`, and `cache_write_tokens` counters @@ -706,6 +720,9 @@ async function sendJobConclusionSpan(spanName, options = {}) { const engineId = awInfo.engine_id || ""; const model = awInfo.model || ""; const staged = awInfo.staged === true; + const itemType = typeof awInfo.context?.item_type === "string" ? awInfo.context.item_type : ""; + const itemNumber = typeof awInfo.context?.item_number === "string" ? awInfo.context.item_number : ""; + const triggerLabel = typeof awInfo.context?.trigger_label === "string" ? awInfo.context.trigger_label : ""; const jobName = process.env.INPUT_JOB_NAME || ""; const runId = process.env.GITHUB_RUN_ID || ""; const runAttempt = awInfo.run_attempt || process.env.GITHUB_RUN_ATTEMPT || "1"; @@ -759,6 +776,9 @@ async function sendJobConclusionSpan(spanName, options = {}) { attributes.push(buildAttr("gh-aw.deployment.state", deploymentStateConclusion)); } attributes.push(buildAttr("gh-aw.staged", staged)); + if (itemType) attributes.push(buildAttr("gh-aw.trigger.item_type", itemType)); + if (itemNumber) attributes.push(buildAttr("gh-aw.trigger.item_number", itemNumber)); + if (triggerLabel) attributes.push(buildAttr("gh-aw.trigger.label", triggerLabel)); if (!isNaN(effectiveTokens) && effectiveTokens > 0) { attributes.push(buildAttr("gh-aw.effective_tokens", effectiveTokens)); } diff --git a/actions/setup/js/send_otlp_span.test.cjs b/actions/setup/js/send_otlp_span.test.cjs index 756338b39f8..d08fb53a16c 100644 --- a/actions/setup/js/send_otlp_span.test.cjs +++ b/actions/setup/js/send_otlp_span.test.cjs @@ -1558,6 +1558,81 @@ describe("sendJobSetupSpan", () => { expect(resourceAttrs).toContainEqual({ key: "deployment.environment", value: { stringValue: "production" } }); }); }); + + describe("trigger item context from aw_info.json", () => { + let readFileSpy; + + beforeEach(() => { + readFileSpy = vi.spyOn(fs, "readFileSync").mockImplementation(() => { + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + }); + }); + + afterEach(() => { + readFileSpy.mockRestore(); + }); + + it("emits gh-aw.trigger.item_type and gh-aw.trigger.item_number from aw_info.context", async () => { + const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, statusText: "OK" }); + vi.stubGlobal("fetch", mockFetch); + + process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "https://traces.example.com"; + + readFileSpy.mockImplementation(filePath => { + if (filePath === "/tmp/gh-aw/aw_info.json") { + return JSON.stringify({ context: { item_type: "issue", item_number: "42", trigger_label: "" } }); + } + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + }); + + await sendJobSetupSpan(); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + const span = body.resourceSpans[0].scopeSpans[0].spans[0]; + expect(span.attributes).toContainEqual({ key: "gh-aw.trigger.item_type", value: { stringValue: "issue" } }); + expect(span.attributes).toContainEqual({ key: "gh-aw.trigger.item_number", value: { stringValue: "42" } }); + const keys = span.attributes.map(a => a.key); + expect(keys).not.toContain("gh-aw.trigger.label"); + }); + + it("emits gh-aw.trigger.label when trigger_label is non-empty", async () => { + const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, statusText: "OK" }); + vi.stubGlobal("fetch", mockFetch); + + process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "https://traces.example.com"; + + readFileSpy.mockImplementation(filePath => { + if (filePath === "/tmp/gh-aw/aw_info.json") { + return JSON.stringify({ context: { item_type: "pull_request", item_number: "99", trigger_label: "copilot" } }); + } + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + }); + + await sendJobSetupSpan(); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + const span = body.resourceSpans[0].scopeSpans[0].spans[0]; + expect(span.attributes).toContainEqual({ key: "gh-aw.trigger.item_type", value: { stringValue: "pull_request" } }); + expect(span.attributes).toContainEqual({ key: "gh-aw.trigger.item_number", value: { stringValue: "99" } }); + expect(span.attributes).toContainEqual({ key: "gh-aw.trigger.label", value: { stringValue: "copilot" } }); + }); + + it("omits trigger attributes when aw_info.json is absent", async () => { + const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, statusText: "OK" }); + vi.stubGlobal("fetch", mockFetch); + + process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "https://traces.example.com"; + + await sendJobSetupSpan(); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + const span = body.resourceSpans[0].scopeSpans[0].spans[0]; + const keys = span.attributes.map(a => a.key); + expect(keys).not.toContain("gh-aw.trigger.item_type"); + expect(keys).not.toContain("gh-aw.trigger.item_number"); + expect(keys).not.toContain("gh-aw.trigger.label"); + }); + }); }); // --------------------------------------------------------------------------- @@ -3183,4 +3258,79 @@ describe("sendJobConclusionSpan", () => { expect(resourceAttrs).toContainEqual({ key: "deployment.environment", value: { stringValue: "production" } }); }); }); + + describe("trigger item context from aw_info.json", () => { + let readFileSpy; + + beforeEach(() => { + readFileSpy = vi.spyOn(fs, "readFileSync").mockImplementation(() => { + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + }); + }); + + afterEach(() => { + readFileSpy.mockRestore(); + }); + + it("emits gh-aw.trigger.item_type and gh-aw.trigger.item_number from aw_info.context", async () => { + const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, statusText: "OK" }); + vi.stubGlobal("fetch", mockFetch); + + process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "https://traces.example.com"; + + readFileSpy.mockImplementation(filePath => { + if (filePath === "/tmp/gh-aw/aw_info.json") { + return JSON.stringify({ context: { item_type: "issue", item_number: "7", trigger_label: "" } }); + } + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + }); + + await sendJobConclusionSpan("gh-aw.job.conclusion"); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + const span = body.resourceSpans[0].scopeSpans[0].spans[0]; + expect(span.attributes).toContainEqual({ key: "gh-aw.trigger.item_type", value: { stringValue: "issue" } }); + expect(span.attributes).toContainEqual({ key: "gh-aw.trigger.item_number", value: { stringValue: "7" } }); + const keys = span.attributes.map(a => a.key); + expect(keys).not.toContain("gh-aw.trigger.label"); + }); + + it("emits gh-aw.trigger.label when trigger_label is non-empty", async () => { + const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, statusText: "OK" }); + vi.stubGlobal("fetch", mockFetch); + + process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "https://traces.example.com"; + + readFileSpy.mockImplementation(filePath => { + if (filePath === "/tmp/gh-aw/aw_info.json") { + return JSON.stringify({ context: { item_type: "pull_request", item_number: "456", trigger_label: "bug" } }); + } + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); + }); + + await sendJobConclusionSpan("gh-aw.job.conclusion"); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + const span = body.resourceSpans[0].scopeSpans[0].spans[0]; + expect(span.attributes).toContainEqual({ key: "gh-aw.trigger.item_type", value: { stringValue: "pull_request" } }); + expect(span.attributes).toContainEqual({ key: "gh-aw.trigger.item_number", value: { stringValue: "456" } }); + expect(span.attributes).toContainEqual({ key: "gh-aw.trigger.label", value: { stringValue: "bug" } }); + }); + + it("omits trigger attributes when aw_info.json is absent", async () => { + const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, statusText: "OK" }); + vi.stubGlobal("fetch", mockFetch); + + process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "https://traces.example.com"; + + await sendJobConclusionSpan("gh-aw.job.conclusion"); + + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + const span = body.resourceSpans[0].scopeSpans[0].spans[0]; + const keys = span.attributes.map(a => a.key); + expect(keys).not.toContain("gh-aw.trigger.item_type"); + expect(keys).not.toContain("gh-aw.trigger.item_number"); + expect(keys).not.toContain("gh-aw.trigger.label"); + }); + }); });