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
4 changes: 4 additions & 0 deletions actions/setup/js/emit_outcome_spans.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const {
sendOTLPToAllEndpoints,
appendToOTLPJSONL,
readJSONIfExists,
buildCustomOTLPAttributes,
} = require("./send_otlp_span.cjs");

const AW_INFO_PATH = "/tmp/gh-aw/aw_info.json";
Expand Down Expand Up @@ -258,6 +259,9 @@ async function main() {
summaryAttributes.push(buildAttr("gh-aw.outcome.types", types.join(",")));
}

// Append user-defined custom attributes from observability.otlp.attributes.
summaryAttributes.push(...buildCustomOTLPAttributes());

Comment on lines +262 to +264
const summarySpan = buildOTLPSpan({
traceId,
spanId: summarySpanId,
Expand Down
56 changes: 56 additions & 0 deletions actions/setup/js/send_otlp_span.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,55 @@ function buildExperimentAttributes(assignments) {
return attrs;
}

// ---------------------------------------------------------------------------
// Custom OTLP attributes (GH_AW_OTLP_ATTRIBUTES)
// ---------------------------------------------------------------------------

/**
* Parse the GH_AW_OTLP_ATTRIBUTES environment variable into a plain object.
* The variable is a JSON-encoded `Record<string, string>` injected by the
* gh-aw compiler from the `observability.otlp.attributes` frontmatter field.
* Returns null when the variable is absent, empty, or not valid JSON.
*
* @returns {Record<string, string> | null}
*/
function parseOTLPCustomAttributes() {
const raw = process.env.GH_AW_OTLP_ATTRIBUTES;
if (!raw) return null;
try {
const parsed = JSON.parse(raw);
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return null;
return /** @type {Record<string, string>} */ (parsed);
} catch {
return null;
}
}

/**
* Build additional OTLP attribute objects from the GH_AW_OTLP_ATTRIBUTES
* environment variable.
*
* Attribute values are used as-is (use GitHub Actions expressions like
* `${{ vars.MY_VALUE }}` in workflow frontmatter for dynamic values).
* Attributes whose value is an empty string are omitted. When no custom
* attributes are configured, an empty array is returned.
*
* @returns {Array<{key: string, value: object}>}
*/
function buildCustomOTLPAttributes() {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/tdd] Function signature doesn't support template expansion as documented.

💡 Missing attributes parameter

buildCustomOTLPAttributes() is called after all standard span attributes are built:

attributes.push(...buildExperimentAttributes(experimentAssignments));
attributes.push(...buildEpisodeAttributesFromContext(awInfo, runId, runAttempt));
attributes.push(...buildCustomOTLPAttributes());  // ← called AFTER attributes are built

But template expansion requires access to the already-computed attributes to resolve variables like {{ gh-aw.episode.id }}. The current signature function buildCustomOTLPAttributes() has no way to access them.

Fix: Change the signature to accept the existing attributes:

function buildCustomOTLPAttributes(existingAttributes = []) {
  const customDefs = parseOTLPCustomAttributes();
  if (!customDefs) return [];

  const result = [];
  for (const [key, value] of Object.entries(customDefs)) {
    if (typeof key !== "string" || !key || typeof value !== "string") continue;
    const expanded = expandTemplate(value, existingAttributes);
    if (expanded !== "") {
      result.push(buildAttr(key, expanded));
    }
  }
  return result;
}

Then update call sites:

attributes.push(...buildCustomOTLPAttributes(attributes));

const customDefs = parseOTLPCustomAttributes();
if (!customDefs) return [];

const result = [];
for (const [key, value] of Object.entries(customDefs)) {
if (typeof key !== "string" || !key || typeof value !== "string") continue;
if (value !== "") {
result.push(buildAttr(key, value));
}
}
return result;
Comment on lines +594 to +616
}

// ---------------------------------------------------------------------------
// HTTP transport
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -1143,6 +1192,8 @@ async function sendJobSetupSpan(options = {}) {
const experimentAssignments = readExperimentAssignments();
attributes.push(...buildExperimentAttributes(experimentAssignments));
attributes.push(...buildEpisodeAttributesFromContext(awInfo, runId, runAttempt));
// Append user-defined custom attributes from observability.otlp.attributes.
attributes.push(...buildCustomOTLPAttributes());

Comment on lines 1192 to 1197
const resourceAttributes = buildGitHubActionsResourceAttributes({
repository,
Expand Down Expand Up @@ -1852,6 +1903,9 @@ async function sendJobConclusionSpan(spanName, options = {}) {
const conclusionExperimentAssignments = readExperimentAssignments();
attributes.push(...buildExperimentAttributes(conclusionExperimentAssignments));

// Append user-defined custom attributes from observability.otlp.attributes.
attributes.push(...buildCustomOTLPAttributes());

// Enrich conclusion span with outcome evaluation fleet metrics when available.
// Written by the outcome-collector workflow's pre-agent step.
const outcomeSummary = readJSONIfExists("/tmp/gh-aw/outcome-summary.json");
Expand Down Expand Up @@ -2094,4 +2148,6 @@ module.exports = {
OTEL_JSONL_PATH,
appendToOTLPJSONL,
buildExperimentAttributes,
parseOTLPCustomAttributes,
buildCustomOTLPAttributes,
};
266 changes: 266 additions & 0 deletions actions/setup/js/send_otlp_span.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ const {
buildExperimentAttributes,
hasProxyConfigured,
resolveEngineId,
parseOTLPCustomAttributes,
buildCustomOTLPAttributes,
} = await import("./send_otlp_span.cjs");

const { readExperimentAssignments, EXPERIMENT_ASSIGNMENTS_PATH } = await import("./experiment_helpers.cjs");
Expand Down Expand Up @@ -5794,3 +5796,267 @@ describe("resolveEngineId", () => {
expect(resolveEngineId({ engine_id: " ", context: { engine_id: "gemini" } })).toBe("gemini");
});
});

// ---------------------------------------------------------------------------
// parseOTLPCustomAttributes
// ---------------------------------------------------------------------------

describe("parseOTLPCustomAttributes", () => {
const savedEnv = {};

beforeEach(() => {
savedEnv.GH_AW_OTLP_ATTRIBUTES = process.env.GH_AW_OTLP_ATTRIBUTES;
delete process.env.GH_AW_OTLP_ATTRIBUTES;
});

afterEach(() => {
if (savedEnv.GH_AW_OTLP_ATTRIBUTES !== undefined) {
process.env.GH_AW_OTLP_ATTRIBUTES = savedEnv.GH_AW_OTLP_ATTRIBUTES;
} else {
delete process.env.GH_AW_OTLP_ATTRIBUTES;
}
});

it("returns null when the env var is not set", () => {
expect(parseOTLPCustomAttributes()).toBeNull();
});

it("returns null when the env var is an empty string", () => {
process.env.GH_AW_OTLP_ATTRIBUTES = "";
expect(parseOTLPCustomAttributes()).toBeNull();
});

it("returns null when the env var is not valid JSON", () => {
process.env.GH_AW_OTLP_ATTRIBUTES = "not-json";
expect(parseOTLPCustomAttributes()).toBeNull();
});

it("returns null when the env var is a JSON array", () => {
process.env.GH_AW_OTLP_ATTRIBUTES = '["a","b"]';
expect(parseOTLPCustomAttributes()).toBeNull();
});

it("returns null when the env var is JSON null", () => {
process.env.GH_AW_OTLP_ATTRIBUTES = "null";
expect(parseOTLPCustomAttributes()).toBeNull();
});

it("returns the parsed object when the env var is a valid JSON object", () => {
process.env.GH_AW_OTLP_ATTRIBUTES = JSON.stringify({
"langfuse.session.id": "my-session",
"langfuse.user.id": "my-user",
});
const result = parseOTLPCustomAttributes();
expect(result).toEqual({
"langfuse.session.id": "my-session",
"langfuse.user.id": "my-user",
});
});
});

// ---------------------------------------------------------------------------
// buildCustomOTLPAttributes
// ---------------------------------------------------------------------------

describe("buildCustomOTLPAttributes", () => {
const savedEnv = {};

beforeEach(() => {
savedEnv.GH_AW_OTLP_ATTRIBUTES = process.env.GH_AW_OTLP_ATTRIBUTES;
delete process.env.GH_AW_OTLP_ATTRIBUTES;
});

afterEach(() => {
if (savedEnv.GH_AW_OTLP_ATTRIBUTES !== undefined) {
process.env.GH_AW_OTLP_ATTRIBUTES = savedEnv.GH_AW_OTLP_ATTRIBUTES;
} else {
delete process.env.GH_AW_OTLP_ATTRIBUTES;
}
});

it("returns an empty array when GH_AW_OTLP_ATTRIBUTES is not set", () => {
expect(buildCustomOTLPAttributes()).toEqual([]);
});

it("returns static attribute values as-is", () => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/tdd] Test validates static values but doesn't test the advertised template expansion feature.

💡 Missing template expansion tests

This test verifies that static attribute values pass through as-is:

process.env.GH_AW_OTLP_ATTRIBUTES = JSON.stringify({
  "langfuse.session.id": "my-session",  // ← static value
  "langfuse.user.id": "my-user",
});

But the PR description and frontmatter documentation promise template variable expansion:

attributes:
  langfuse.session.id: "{{ gh-aw.episode.id }}"  # ← template, should expand
  user.id: "{{ gh-aw.run.actor }}"

What's missing: Tests that verify template expansion resolves variables from the span's existing attributes:

it("expands template variables from span attributes", () => {
  process.env.GH_AW_OTLP_ATTRIBUTES = JSON.stringify({
    "langfuse.session.id": "{{ gh-aw.episode.id }}",
    "user.id": "{{ github.actor }}"
  });
  
  const spanAttributes = [
    { key: "gh-aw.episode.id", value: { stringValue: "ep-12345" } },
    { key: "github.actor", value: { stringValue: "pelikhan" } }
  ];
  
  const result = buildCustomOTLPAttributes(spanAttributes);
  expect(result).toContainEqual({ key: "langfuse.session.id", value: { stringValue: "ep-12345" } });
  expect(result).toContainEqual({ key: "user.id", value: { stringValue: "pelikhan" } });
});

process.env.GH_AW_OTLP_ATTRIBUTES = JSON.stringify({
"langfuse.session.id": "my-session",
"langfuse.user.id": "my-user",
});
const result = buildCustomOTLPAttributes();
expect(result).toContainEqual({ key: "langfuse.session.id", value: { stringValue: "my-session" } });
expect(result).toContainEqual({ key: "langfuse.user.id", value: { stringValue: "my-user" } });
});

it("omits custom attributes whose value is an empty string", () => {
process.env.GH_AW_OTLP_ATTRIBUTES = JSON.stringify({
"my.attr": "",
});
const result = buildCustomOTLPAttributes();
expect(result).toHaveLength(0);
});

it("preserves static attribute values", () => {
process.env.GH_AW_OTLP_ATTRIBUTES = JSON.stringify({
"deployment.environment": "production",
});
const result = buildCustomOTLPAttributes();
expect(result).toContainEqual({ key: "deployment.environment", value: { stringValue: "production" } });
});
});

// ---------------------------------------------------------------------------
// sendJobSetupSpan – custom attributes integration
// ---------------------------------------------------------------------------

describe("sendJobSetupSpan custom attributes", () => {
const savedEnv = {};
const envKeys = [
"GH_AW_OTLP_ENDPOINTS",
"GH_AW_OTLP_ATTRIBUTES",
"GITHUB_RUN_ID",
"GITHUB_RUN_ATTEMPT",
"GITHUB_ACTOR",
"GITHUB_REPOSITORY",
"GH_AW_SETUP_AW_CONTEXT",
];
let mkdirSpy, appendSpy;

beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
for (const k of envKeys) {
savedEnv[k] = process.env[k];
delete process.env[k];
}
mkdirSpy = vi.spyOn(fs, "mkdirSync").mockImplementation(() => {});
appendSpy = vi.spyOn(fs, "appendFileSync").mockImplementation(() => {});
});

afterEach(() => {
vi.unstubAllGlobals();
for (const k of envKeys) {
if (savedEnv[k] !== undefined) {
process.env[k] = savedEnv[k];
} else {
delete process.env[k];
}
}
mkdirSpy.mockRestore();
appendSpy.mockRestore();
});

it("emits langfuse.session.id and langfuse.user.id when configured via GH_AW_OTLP_ATTRIBUTES", async () => {
const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, statusText: "OK" });
vi.stubGlobal("fetch", mockFetch);

process.env.GH_AW_OTLP_ENDPOINTS = JSON.stringify([{ url: "https://langfuse.example.com" }]);
process.env.GITHUB_ACTOR = "octocat";
process.env.GITHUB_RUN_ID = "99001122";
process.env.GITHUB_RUN_ATTEMPT = "1";
process.env.GH_AW_SETUP_AW_CONTEXT = JSON.stringify({
episode_id: "99001122-1:owner/repo/.github/workflows/test.lock.yml@refs/heads/main",
});
process.env.GH_AW_OTLP_ATTRIBUTES = JSON.stringify({
"langfuse.session.id": "my-session-id",
"session.id": "my-session-id",
"langfuse.user.id": "my-user-id",
"user.id": "my-user-id",
});

const readFileSpy = vi.spyOn(fs, "readFileSync").mockImplementation(() => {
throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
});

await sendJobSetupSpan();
readFileSpy.mockRestore();

const body = JSON.parse(mockFetch.mock.calls[0][1].body);
const span = body.resourceSpans[0].scopeSpans[0].spans[0];
const attrMap = Object.fromEntries(span.attributes.map(a => [a.key, a.value.stringValue]));

expect(attrMap["langfuse.session.id"]).toBe("my-session-id");
expect(attrMap["session.id"]).toBe("my-session-id");
expect(attrMap["langfuse.user.id"]).toBe("my-user-id");
expect(attrMap["user.id"]).toBe("my-user-id");

});
});

// ---------------------------------------------------------------------------
// sendJobConclusionSpan – custom attributes integration
// ---------------------------------------------------------------------------

describe("sendJobConclusionSpan custom attributes", () => {
const savedEnv = {};
const envKeys = [
"GH_AW_OTLP_ENDPOINTS",
"GH_AW_OTLP_ATTRIBUTES",
"GITHUB_RUN_ID",
"GITHUB_RUN_ATTEMPT",
"GITHUB_ACTOR",
"GITHUB_REPOSITORY",
"GITHUB_AW_OTEL_TRACE_ID",
"GITHUB_AW_OTEL_PARENT_SPAN_ID",
"GH_AW_AGENT_CONCLUSION",
];
let mkdirSpy, appendSpy;

beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
for (const k of envKeys) {
savedEnv[k] = process.env[k];
delete process.env[k];
}
mkdirSpy = vi.spyOn(fs, "mkdirSync").mockImplementation(() => {});
appendSpy = vi.spyOn(fs, "appendFileSync").mockImplementation(() => {});
});

afterEach(() => {
vi.unstubAllGlobals();
for (const k of envKeys) {
if (savedEnv[k] !== undefined) {
process.env[k] = savedEnv[k];
} else {
delete process.env[k];
}
}
mkdirSpy.mockRestore();
appendSpy.mockRestore();
});

it("emits langfuse.session.id and langfuse.user.id on conclusion spans when configured", async () => {
const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, statusText: "OK" });
vi.stubGlobal("fetch", mockFetch);

process.env.GH_AW_OTLP_ENDPOINTS = JSON.stringify([{ url: "https://langfuse.example.com" }]);
process.env.GITHUB_ACTOR = "monalisa";
process.env.GITHUB_RUN_ID = "88002233";
process.env.GITHUB_RUN_ATTEMPT = "1";
process.env.GITHUB_AW_OTEL_TRACE_ID = "a".repeat(32);
process.env.GH_AW_OTLP_ATTRIBUTES = JSON.stringify({
"langfuse.session.id": "my-session-id",
"langfuse.user.id": "my-user-id",
});

const readFileSpy = vi.spyOn(fs, "readFileSync").mockImplementation(filePath => {
if (String(filePath).includes("aw_info.json")) {
return JSON.stringify({
context: {
episode_id: "88002233-1:owner/repo/.github/workflows/test.lock.yml@refs/heads/main",
},
});
}
throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
});

await sendJobConclusionSpan("gh-aw.agent.conclusion");
readFileSpy.mockRestore();

const body = JSON.parse(mockFetch.mock.calls[0][1].body);
const span = body.resourceSpans[0].scopeSpans[0].spans[0];
const attrMap = Object.fromEntries(span.attributes.map(a => [a.key, a.value.stringValue]));

expect(attrMap["langfuse.session.id"]).toBe("my-session-id");
expect(attrMap["langfuse.user.id"]).toBe("my-user-id");
});
});
Loading