Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions actions/setup/js/send_otlp_span.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 : "";
Comment on lines +475 to +477
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

itemType, itemNumber, and triggerLabel are taken verbatim from awInfo.context and later emitted when truthy. If any of these fields are whitespace-only (e.g. " "), they will be emitted as span attributes even though they are effectively empty. Consider normalizing with .trim() (and using the trimmed value for both the if (...) check and the attribute value) so empty/whitespace values are cleanly omitted, matching the stated behavior of omitting empty strings.

This issue also appears on line 723 of the same file.

Suggested change
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 itemType = typeof awInfo.context?.item_type === "string" ? awInfo.context.item_type.trim() : "";
const itemNumber = typeof awInfo.context?.item_number === "string" ? awInfo.context.item_number.trim() : "";
const triggerLabel = typeof awInfo.context?.trigger_label === "string" ? awInfo.context.trigger_label.trim() : "";

Copilot uses AI. Check for mistakes.

const traceId = optionsTraceId || inputTraceId || contextTraceId || generateTraceId();

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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));
}
Expand Down
150 changes: 150 additions & 0 deletions actions/setup/js/send_otlp_span.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
});

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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");
});
});
});
Loading