Skip to content

[aw] Design Decision Gate πŸ—οΈ failedΒ #33894

@github-actions

Description

@github-actions

Workflow Failure

Workflow: Design Decision Gate πŸ—οΈ
Branch: HEAD
Run: https://github.com/github/gh-aw/actions/runs/26259479701

⚠️ Engine Failure: The claude engine terminated unexpectedly.

Last agent output:

2026-05-21T23:46:12.015Z [INFO] [Stall] tool_dispatch_start tool=Read toolUseId=toolu_01DrJJPoKJrrXy3wiBeosMcV permissionDecisionMs=1
2026-05-21T23:46:12.016Z [INFO] [Stall] tool_dispatch_end tool=Read toolUseId=toolu_01DrJJPoKJrrXy3wiBeosMcV outcome=ok durationMs=1
2026-05-21T23:46:12.019Z [DEBUG] LSP Diagnostics: getLSPDiagnosticAttachments called
2026-05-21T23:46:12.019Z [DEBUG] LSP Diagnostics: Checking registry - 0 pending
2026-05-21T23:46:12.019Z [DEBUG] Hooks: Found 0 total hooks in registry
2026-05-21T23:46:12.019Z [DEBUG] Hooks: checkForNewResponses returning 0 responses
{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01DrJJPoKJrrXy3wiBeosMcV","type":"tool_result","content":"6630\tdiff --git a/actions/setup/sh/mask_otlp_attributes.sh b/actions/setup/sh/mask_otlp_attributes.sh\n6631\tnew file mode 100755\n6632\tindex 00000000000..8b83cfc4f26\n6633\t--- /dev/null\n6634\t+++ b/actions/setup/sh/mask_otlp_attributes.sh\n6635\t@@ -0,0 +1,53 @@\n6636\t+#!/usr/bin/env bash\n6637\t+set +o histexpand\n6638\t+\n6639\t+#\n6640\t+# mask_otlp_attributes.sh - Mask GH_AW_OTLP_ATTRIBUTES values from GitHub Actions logs\n6641\t+#\n6642\t+# Issues the ::add-mask:: workflow command for every value found in the\n6643\t+# GH_AW_OTLP_ATTRIBUTES JSON object so that user-supplied custom OTLP span\n6644\t+# attribute values (e.g. session IDs, user IDs) do not leak into GitHub\n6645\t+# Actions runner logs (including debug/step-debug logs).\n6646\t+#\n6647\t+# GH_AW_OTLP_ATTRIBUTES is a JSON-encoded Record<string, string> injected by\n6648\t+# the gh-aw compiler from the `observability.otlp.attributes` frontmatter field.\n6649\t+# Each value is masked individually; empty values are skipped.\n6650\t+#\n6651\t+# Requires node to be available on PATH (it is always present on GitHub Actions\n6652\t+# runners when the gh-aw setup step has run).\n6653\t+#\n6654\t+# Exit codes:\n6655\t+#   0 - Success (variable may be absent or empty, which is a no-op)\n6656\t+\n6657\t+set -euo pipefail\n6658\t+\n6659\t+_attrs=\"${GH_AW_OTLP_ATTRIBUTES:-}\"\n6660\t+[ -z \"$_attrs\" ] && exit 0\n6661\t+\n6662\t+# Use node to extract the string values from the JSON object and print one\n6663\t+# per line (null/empty values are omitted).\n6664\t+_GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node)\n6665\t+\n6666\t+# Read the values into an array, then issue ::add-mask:: for each non-empty one.\n6667\t+mapfile -t _values < <(\n6668\t+  printf '%s' \"$_attrs\" | \"$_GH_AW_NODE\" -e '\n6669\t+    let raw = \"\";\n6670\t+    process.stdin.on(\"data\", d => { raw += d; });\n6671\t+    process.stdin.on(\"end\", () => {\n6672\t+      try {\n6673\t+        const obj = JSON.parse(raw);\n6674\t+        if (obj !== null && typeof obj === \"object\" && !Array.isArray(obj)) {\n6675\t+          for (const v of Object.values(obj)) {\n6676\t+            if (typeof v === \"string\" && v.length > 0) {\n6677\t+              process.stdout.write(v + \"\\n\");\n6678\t+            }\n6679\t+          }\n6680\t+        }\n6681\t+      } catch { /* invalid JSON – no-op */ }\n6682\t+    });\n6683\t+  '\n6684\t+)\n6685\t+\n6686\t+for _val in \"${_values[@]}\"; do\n6687\t+  [ -n \"$_val\" ] && echo '::add-mask::'\"$_val\"\n6688\t+done\n6689\tdiff --git a/pkg/parser/import_field_extractor.go b/pkg/parser/import_field_extractor.go\n6690\tindex 1a7907bfe84..52f479bd420 100644\n6691\t--- a/pkg/parser/import_field_extractor.go\n6692\t+++ b/pkg/parser/import_field_extractor.go\n6693\t@@ -770,11 +770,13 @@ func extractOTLPEndpointsFromObsMap(obs map[string]any) []observabilityImportEnd\n6694\t // mergeObservabilityConfigs takes a slice of observability config JSON strings (one per\n6695\t // import), extracts all OTLP endpoint entries from each (supporting string, object, and\n6696\t // array forms), deduplicates by URL (first occurrence wins), and returns a single merged\n6697\t-// observability JSON string with all endpoints expressed as an array.\n6698\t-// Returns \"\" when no valid endpoints are found.\n6699\t+// observability JSON string with all endpoints expressed as an array.  Custom OTLP\n6700\t+// attributes are also merged across imports (first occurrence wins per key).\n6701\t+// Returns \"\" when no valid endpoints or attributes are found.\n6702\t func mergeObservabilityConfigs(configs []string) string {\n6703\t \tseen := make(map[string]bool)\n6704\t \tvar allEndpoints []observabilityImportEndpoint\n6705\t+\tmergedAttrs := make(map[string]string)\n6706\t \n6707\t \tfor i, cfgJSON := range configs {\n6708\t \t\tif cfgJSON == \"\" {\n6709\t@@ -791,19 +793,28 @@ func mergeObservabilityConfigs(configs []string) string {\n6710\t \t\t\t\tallEndpoints = append(allEndpoints, e)\n6711\t \t\t\t}\n6712\t \t\t}\n6713\t+\t\tfor k, v := range extractOTLPAttributesFromObsMap(obs) {\n6714\t+\t\t\tif _, exists := mergedAttrs[k]; !exists {\n6715\t+\t\t\t\tmergedAttrs[k] = v\n6716\t+\t\t\t}\n6717\t+\t\t}\n6718\t \t}\n6719\t \n6720\t-\tif len(allEndpoints) == 0 {\n6721\t+\tif len(allEndpoints) == 0 && len(mergedAttrs) == 0 {\n6722\t \t\treturn \"\"\n6723\t \t}\n6724\t \n6725\t \t// Produce a merged config with the endpoint field as an array so that the\n6726\t-\t// workflow package's collectAllOTLPEndpoints handles it uniformly.\n6727\t-\tmerged := map[string]any{\n6728\t-\t\t\"otlp\": map[string]any{\n6729\t-\t\t\t\"endpoint\": allEndpoints,\n6730\t-\t\t},\n6731\t+\t// workflow package's collectAllOTLPEndpoints handles it uniformly.  Include\n6732\t+\t// any merged custom attributes so the orchestrator can propagate them.\n6733\t+\totlpMap := map[string]any{}\n6734\t+\tif len(allEndpoints) > 0 {\n6735\t+\t\totlpMap[\"endpoint\"] = allEndpoints\n6736\t \t}\n6737\t+\tif len(mergedAttrs) > 0 {\n6738\t+\t\totlpMap[\"attributes\"] = mergedAttrs\n6739\t+\t}\n6740\t+\tmerged := map[string]any{\"otlp\": otlpMap}\n6741\t \tb, err := json.Marshal(merged)\n6742\t \tif err != nil {\n6743\t \t\tparserLog.Printf(\"Failed to marshal %d merged OTLP endpoints: %v\", len(allEndpoints), err)\n6744\t@@ -812,6 +823,44 @@ func mergeObservabilityConfigs(configs []string) string {\n6745\t \treturn string(b)\n6746\t }\n6747\t \n6748\t+// extractOTLPAttributesFromObsMap reads the custom OTLP attributes map from a\n6749\t+// raw observability section (as parsed from an import's frontmatter).  Only\n6750\t+// string values are accepted; non-string values are silently ignored.\n6751\t+// Returns nil when the field is absent or empty.\n6752\t+//\n6753\t+// Note: this intentionally duplicates the logic of\n6754\t+// workflow.extractOTLPCustomAttributesFromObsMap.  The parser package must not\n6755\t+// import the workflow package (circular-dependency risk), so the helper lives\n6756\t+// here as a local copy.  Both implementations must stay in sync.\n6757\t+func extractOTLPAttributesFromObsMap(obs map[string]any) map[string]string {\n6758\t+\tif obs == nil {\n6759\t+\t\treturn nil\n6760\t+\t}\n6761\t+\totlpAny, ok := obs[\"otlp\"]\n6762\t+\tif !ok {\n6763\t+\t\treturn nil\n6764\t+\t}\n6765\t+\totlpMap, ok := otlpAny.(map[string]any)\n6766\t+\tif !ok {\n6767\t+\t\treturn nil\n6768\t+\t}\n6769\t+\tattrsAny, ok := otlpMap[\"attributes\"]\n6770\t+\tif !ok {\n6771\t+\t\treturn nil\n6772\t+\t}\n6773\t+\tattrsMap, ok := attrsAny.(map[string]any)\n6774\t+\tif !ok {\n6775\t+\t\treturn nil\n6776\t+\t}\n6777\t+\tresult := make(map[string]string, len(attrsMap))\n6778\t+\tfor k, v := range attrsMap {\n6779\t+\t\tif s, ok := v.(string); ok && k != \"\" {\n6780\t+\t\t\tresult[k] = s\n6781\t+\t\t}\n6782\t+\t}\n6783\t+\treturn result\n6784\t+}\n6785\t+\n6786\t // suitable for use in a {{#runtime-import ...}} macro.\n6787\t //\n6788\t // The rules are:\n6789\tdiff --git a/pkg/workflow/compiler_activation_job_builder.go b/pkg/workflow/compiler_activation_job_builder.go\n6790\tindex f03bc8aa805..2659b5bc9c4 100644\n6791\t--- a/pkg/workflow/compiler_activation_job_builder.go\n6792\t+++ b/pkg/workflow/compiler_activation_job_builder.go\n6793\t@@ -111,6 +111,9 @@ func (c *Compiler) newActivationJobBuildContext(\n6794\t \tif isOTLPHeadersPresent(data) {\n6795\t \t\tctx.steps = append(ctx.steps, generateOTLPHeadersMaskStep())\n6796\t \t}\n6797\t+\tif isOTLPAttributesPresent(data) {\n6798\t+\t\tctx.steps = append(ctx.steps, generateOTLPAttributesMaskStep())\n6799\t+\t}\n6800\t \tif hasWorkflowCallTrigger(data.On) && !data.InlinedImports {\n6801\t \t\tcompilerActivationJobLog.Print(\"Adding resolve-host-repo step for workflow_call trigger\")\n6802\t \t\tctx.steps = append(ctx.steps, c.generateResolveHostRepoStep(data))\n6803\tdiff --git a/pkg/workflow/compiler_orchestrator_workflow.go b/pkg/workflow/compiler_orchestrator_workflow.go\n6804\tindex 784d0c7cf16..4ba0001db02 100644\n6805\t--- a/pkg/workflow/compiler_orchestrator_workflow.go\n6806\t+++ b/pkg/workflow/compiler_orchestrator_workflow.go\n6807\t@@ -175,10 +175,11 @@ func (c *Compiler) ParseWorkflowFile(markdownPath string) (*WorkflowData, error)\n6808\t \t\treturn nil, formatCompilerError(cleanPath, \"error\", err.Error(), err)\n6809\t \t}\n6810\t \n6811\t-\t// Merge observability endpoints from imports with those from the main workflow.\n6812\t-\t// All OTLP endpoints from both sources are combined into an array, deduplicating\n6813\t-\t// by URL (main workflow endpoints take precedence). This allows multiple shared\n6814\t-\t// workflows each defining their own OTLP endpoint to fan out to all collectors.\n6815\t+\t// Merge observability endpoints and custom attributes from imports with those\n6816\t+\t// from the main workflow.  All OTLP endpoints from both sources are combined\n6817\t+\t// into an array, deduplicating by URL (main workflow endpoints take precedence).\n6818\t+\t// Custom attributes follow the same precedence: main workflow values override\n6819\t+\t// imported ones for the same key.\n6820\t \tif obs := engineSetup.importsResult.MergedObservability; obs != \"\" {\n6821\t \t\tvar importedObs map[string]any\n6822\t \t\tif err := json.Unmarshal([]byte(obs), &importedObs); err == nil {\n6823\t@@ -207,14 +208,29 @@ func (c *Compiler) ParseWorkflowFile(markdownPath string) (*WorkflowData, error)\n6824\t \t\t\t\t}\n6825\t \t\t\t}\n6826\t \n6827\t-\t\t\tif len(mergedEndpoints) > 0 {\n6828\t+\t\t\t// Merge custom OTLP attributes: import attrs provide defaults, main\n6829\t+\t\t\t// workflow attrs override them.\n6830\t+\t\t\tmainAttrs := extractOTLPCustomAttributesFromObsMap(mainObs)\n6831\t+\t\t\timportAttrs := extractOTLPCustomAttributesFromObsMap(importedObs)\n6832\t+\t\t\t// mergeOTLPCustomAttributes(base, override) β€” base wins, so pass main as base.\n6833\t+\t\t\tmergedAttrs := mergeOTLPCustomAttributes(mainAttrs, importAttrs)\n6834\t+\n6835\t+\t\t\tif len(mergedEndpoints) > 0 || len(mergedAttrs) > 0 {\n6836\t \t\t\t\tmainCount := len(mergedEndpoints) - importAdded\n6837\t+\t\t\t\tnewOTLP := map[string]any{}\n6838\t+\t\t\t\tif len(mergedEndpoints) > 0 {\n6839\t+\t\t\t\t\tnewOTLP[\"endpoint\"] = mergedEndpoints\n6840\t+\t\t\t\t}\n6841\t+\t\t\t\tif len(mergedAttrs) > 0 {\n6842\t+\t\t\t\t\tnewOTLP[\"attributes\"] = mergedAttrs\n6843\t+\t\t\t\t}\n6844\t \t\t\t\tworkflowData.RawFrontmatter[\"observability\"] = map[string]any{\n6845\t-\t\t\t\t\t\"otlp\": map[string]any{\n6846\t-\t\t\t\t\t\t\"endpoint\": mergedEndpoints,\n6847\t-\t\t\t\t\t},\n6848\t+\t\t\t\t\t\"otlp\": newOTLP,\n6849\t \t\t\t\t}\n6850\t \t\t\t\torchestratorWorkflowLog.Printf(\"Merged OTLP endpoints into RawFrontmatter: %d from main workflow, %d from imports (%d total)\", mainCount, importAdded, len(mergedEndpoints))\n6851\t+\t\t\t\tif len(mergedAttrs) > 0 {\n6852\t+\t\t\t\t\torchestratorWorkflowLog.Printf(\"Merged %d custom OTLP attributes into RawFrontmatter\", len(mergedAttrs))\n6853\t+\t\t\t\t}\n6854\t \t\t\t}\n6855\t \t\t}\n6856\t \t}\n6857\tdiff --git a/pkg/workflow/compiler_safe_outputs_job.go b/pkg/workflow/compiler_safe_outputs_job.go\n6858\tindex 319c7c0a774..597a20a2f59 100644\n6859\t--- a/pkg/workflow/compiler_safe_outputs_job.go\n6860\t+++ b/pkg/workflow/compiler_safe_outputs_job.go\n6861\t@@ -97,6 +97,10 @@ func (c *Compiler) buildSafeOutputsSetupAndDownloadSteps(data *WorkflowData, age\n6862\t \tif isOTLPHeadersPresent(data) {\n6863\t \t\tsteps = append(steps, generateOTLPHeadersMaskStep())\n6864\t \t}\n6865\t+\t// Mask custom OTLP attribute values so user-supplied values cannot leak into runner logs.\n6866\t+\tif isOTLPAttributesPresent(data) {\n6867\t+\t\tsteps = append(steps, generateOTLPAttributesMaskStep())\n6868\t+\t}\n6869\t \n6870\t \t// Add artifact download steps after setup.\n6871\t \t// In workflow_call context, use the per-invocation prefix to avoid artifact name clashes.\n6872\tdiff --git a/pkg/workflow/compiler_yaml_main_job.go b/pkg/workflow/compiler_yaml_main_job.go\n6873\tindex f2d3489bc3c..a9c57c5f1d3 100644\n6874\t--- a/pkg/workflow/compiler_yaml_main_job.go\n6875\t+++ b/pkg/workflow/compiler_yaml_main_job.go\n6876\t@@ -59,6 +59,10 @@ func (c *Compiler) generateInitialAndCheckoutSteps(yaml *strings.Builder, data *\n6877\t \tif isOTLPHeadersPresent(data) {\n6878\t \t\tyaml.WriteString(generateOTLPHeadersMaskStep())\n6879\t \t}\n6880\t+\t// Mask custom OTLP attribute values so user-supplied values cannot leak into runner logs.\n6881\t+\tif isOTLPAttributesPresent(data) {\n6882\t+\t\tyaml.WriteString(generateOTLPAttributesMaskStep())\n6883\t+\t}\n6884\t \n6885\t \t// Add pre-steps before checkout and the subsequent built-in steps in this agent job.\n6886\t \t// This allows users to mint short-lived tokens (via custom actions) in the same\n6887\tdiff --git a/pkg/workflow/frontmatter_types.go b/pkg/workflow/frontmatter_types.go\n6888\tindex 7e97f6b3037..c542d9f97c5 100644\n6889\t--- a/pkg/workflow/frontmatter_types.go\n6890\t+++ b/pkg/workflow/frontmatter_types.go\n6891\t@@ -235,6 +235,20 @@ type OTLPConfig struct {\n6892\t \t// This setting affects MCP gateway setup only. Other OTLP-aware steps still\n6893\t \t// receive workflow-level OTEL_* environment variables.\n6894\t \tIfMissing string `json:\"if-missing,omitempty\"`\n6895\t+\n6896\t+\t// Attributes defines additional custom key-value string attributes to attach\n6897\t+\t// to every OTLP span emitted by this workflow (setup, agent, and conclusion).\n6898\t+\t// Values support template variables using {{ variable }} syntax, where the\n6899\t+\t// variable name is any OTLP attribute key already computed for the span\n6900\t+\t// (e.g. {{ github.aw.episode.id }}, {{ github.actor }}).\n6901\t+\t//\n6902\t+\t// Example – emit Langfuse session/user attributes alongside the standard ones:\n6903\t+\t//   attributes:\n6904\t+\t//     langfuse.session.id: \"{{ github.aw.episode.id }}\"\n6905\t+\t//     session.id:          \"{{ github.aw.episode.id }}\"\n6906\t+\t//     langfuse.user.id:    \"{{ github.actor }}\"\n6907\t+\t//     user.id:             \"{{ github.actor }}\"\n6908\t+\tAttributes map[string]string `json:\"attributes,omitempty\"`\n6909\t }\n6910\t \n6911\t // ObservabilityConfig represents workflow observability options.\n6912\tdiff --git a/pkg/workflow/observability_otlp.go b/pkg/workflow/observability_otlp.go\n6913\tindex 4c6eb20e7fe..f8bb3b824d0 100644\n6914\t--- a/pkg/workflow/observability_otlp.go\n6915\t+++ b/pkg/workflow/observability_otlp.go\n6916\t@@ -240,6 +240,29 @@ func generateOTLPHeadersMaskStep() string {\n6917\t \treturn sb.String()\n6918\t }\n6919\t \n6920\t+// isOTLPAttributesPresent returns true when GH_AW_OTLP_ATTRIBUTES has been\n6921\t+// injected into the workflow-level env block.  This indicates that attribute\n6922\t+// value masking is needed so that user-supplied values do not leak into\n6923\t+// GitHub Actions runner logs.\n6924\t+func isOTLPAttributesPresent(data *WorkflowData) bool {\n6925\t+\tif data == nil {\n6926\t+\t\treturn false\n6927\t+\t}\n6928\t+\treturn strings.Contains(data.Env, \"GH_AW_OTLP_ATTRIBUTES\")\n6929\t+}\n6930\t+\n6931\t+// generateOTLPAttributesMaskStep returns a GitHub Actions step that runs\n6932\t+// mask_otlp_attributes.sh to issue the ::add-mask:: workflow command for every\n6933\t+// value in the GH_AW_OTLP_ATTRIBUTES JSON object.  Masking the values prevents\n6934\t+// user-supplied custom span attribute values (e.g. session IDs, user IDs) from\n6935\t+// appearing in plaintext in GitHub Actions runner logs.\n6936\t+func generateOTLPAttributesMaskStep() string {\n6937\t+\tvar sb strings.Builder\n6938\t+\tsb.WriteString(\"      - name: Mask OTLP custom attribute values\\n\")\n6939\t+\tsb.WriteString(\"        run: bash \\\"${RUNNER_TEMP}/gh-aw/actions/mask_otlp_attributes.sh\\\"\\n\")\n6940\t+\treturn sb.String()\n6941\t+}\n6942\t+\n6943\t // otlpEndpointEntry is the wire format used when encoding the GH_AW_OTLP_ENDPOINTS\n6944\t // environment variable as a JSON array.  Each entry carries the endpoint URL and\n6945\t // its optional normalized (comma-separated key=value) headers string.\n6946\t@@ -248,6 +271,96 @@ type otlpEndpointEntry struct {\n6947\t \tHeaders string `json:\"headers,omitempty\"`\n6948\t }\n6949\t \n6950\t+// collectOTLPCustomAttributes reads the `observability.otlp.attributes` map from\n6951\t+// a raw frontmatter map and returns it as a map[string]string.  Only string values\n6952\t+// are accepted; non-string values are silently ignored.  Returns nil when the\n6953\t+// field is absent or empty.\n6954\t+func collectOTLPCustomAttributes(frontmatter map[string]any) map[string]string {\n6955\t+\tif frontmatter == nil {\n6956\t+\t\treturn nil\n6957\t+\t}\n6958\t+\tobsAny, ok := frontmatter[\"observability\"]\n6959\t+\tif !ok {\n6960\t+\t\treturn nil\n6961\t+\t}\n6962\t+\tobsMap, ok := obsAny.(map[string]any)\n6963\t+\tif !ok {\n6964\t+\t\treturn nil\n6965\t+\t}\n6966\t+\treturn extractOTLPCustomAttributesFromObsMap(obsMap)\n6967\t+}\n6968\t+\n6969\t+// extractOTLPCustomAttributesFromObsMap reads the `otlp.attributes` map from\n6970\t+// a raw observability section map (i.e. the value of the \"observability\" key in\n6971\t+// the frontmatter) and returns it as a map[string]string.  Only string values are\n6972\t+// accepted; non-string values are silently ignored.  Returns nil when the field is\n6973\t+// absent or empty.\n6974\t+func extractOTLPCustomAttributesFromObsMap(obsMap map[string]any) map[string]string {\n6975\t+\tif obsMap == nil {\n6976\t+\t\treturn nil\n6977\t+\t}\n6978\t+\totlpAny, ok := obsMap[\"otlp\"]\n6979\t+\tif !ok {\n6980\t+\t\treturn nil\n6981\t+\t}\n6982\t+\totlpMap, ok := otlpAny.(map[string]any)\n6983\t+\tif !ok {\n6984\t+\t\treturn nil\n6985\t+\t}\n6986\t+\tattrsAny, ok := otlpMap[\"attributes\"]\n6987\t+\tif !ok {\n6988\t+\t\treturn nil\n6989\t+\t}\n6990\t+\tattrsMap, ok := attrsAny.(map[string]any)\n6991\t+\tif !ok {\n6992\t+\t\treturn nil\n6993\t+\t}\n6994\t+\tresult := make(map[string]string, len(attrsMap))\n6995\t+\tfor k, v := range attrsMap {\n6996\t+\t\tif s, ok := v.(string); ok && k != \"\" {\n6997\t+\t\t\tresult[k] = s\n6998\t+\t\t}\n6999\t+\t}\n7000\t+\tif len(result) == 0 {\n7001\t+\t\treturn nil\n7002\t+\t}\n7003\t+\treturn result\n7004\t+}\n7005\t+\n7006\t+// encodeOTLPCustomAttributes serialises a map[string]string of custom OTLP span\n7007\t+// attributes to a compact JSON string suitable for use as the GH_AW_OTLP_ATTRIBUTES\n7008\t+// environment variable.  Returns an empty string when the map is nil/empty or\n7009\t+// serialisation fails.\n7010\t+func encodeOTLPCustomAttributes(attrs map[string]string) string {\n7011\t+\tif len(attrs) == 0 {\n7012\t+\t\treturn \"\"\n7013\t+\t}\n7014\t+\tb, err := json.Marshal(attrs)\n7015\t+\tif err != nil {\n7016\t+\t\totlpLog.Printf(\"Failed to encode OTLP custom attributes: %v\", err)\n7017\t+\t\treturn \"\"\n7018\t+\t}\n7019\t+\treturn string(b)\n7020\t+}\n7021\t+\n7022\t+// mergeOTLPCustomAttributes merges two custom-attribute maps; values in base\n7023\t+// take precedence over values in override when the same key is present in both.\n7024\t+// Returns nil when both inputs are empty.\n7025\t+func mergeOTLPCustomAttributes(base, override map[string]string) map[string]string {\n7026\t+\tif len(base) == 0 && len(override) == 0 {\n7027\t+\t\treturn nil\n7028\t+\t}\n7029\t+\tmerged := make(map[string]string, len(base)+len(override))\n7030\t+\tfor k, v := range override {\n7031\t+\t\tmerged[k] = v\n7032\t+\t}\n7033\t+\t// base takes precedence\n7034\t+\tfor k, v := range base {\n7035\t+\t\tmerged[k] = v\n7036\t+\t}\n7037\t+\treturn merged\n7038\t+}\n7039\t+\n7040\t // collectAllOTLPEndpoints reads the `observability.otlp.endpoint` field from the raw\n7041\t // frontmatter and returns all configured endpoint entries. The `endpoint` field may be:\n7042\t //\n7043\t@@ -418,6 +531,10 @@ func allOTLPHeaders(entries []otlpEndpointEntry) string {\n7044\t //     injected for the first endpoint (backward compat) and GH_AW_OTLP_ALL_HEADERS\n7045\t //     is injected with all headers across every endpoint (for secret masking).\n7046\t //\n7047\t+//  5. When observability.otlp.attributes is configured, GH_AW_OTLP_ATTRIBUTES is\n7048\t+//     injected as a JSON-encoded map so that span-emitting scripts can append custom\n7049\t+//     attributes (including Langfuse session/user IDs) to every span.\n7050\t+//\n7051\t // When no OTLP endpoint is configured the function is a no-op.\n7052\t func (c *Compiler) injectOTLPConfig(workflowData *WorkflowData) {\n7053\t \t// Collect all endpoint entries from the endpoint field (string, object, or array).\n7054\t@@ -488,6 +605,22 @@ func (c *Compiler) injectOTLPConfig(workflowData *WorkflowData) {\n7055\t \t\totlpLog.Printf(\"Injected GH_AW_OTLP_IF_MISSING env var (%s)\", ifMissingMode)\n7056\t \t}\n7057\t \n7058\t+\t// 5. Inject GH_AW_OTLP_ATTRIBUTES (JSON object) for custom per-span attributes.\n7059\t+\t//    Attributes from RawFrontmatter take precedence; ParsedFrontmatter is the\n7060\t+\t//    fallback for workflows that were parsed but whose RawFrontmatter was later\n7061\t+\t//    modified (e.g. during observability merge in the orchestrator).\n7062\t+\tcustomAttrs := collectOTLPCustomAttributes(workflowData.RawFrontmatter)\n7063\t+\tif len(customAttrs) == 0 && workflowData.ParsedFrontmatter != nil &&\n7064\t+\t\tworkflowData.ParsedFrontmatter.Observability != nil &&\n7065\t+\t\tworkflowData.ParsedFrontmatter.Observability.OTLP != nil {\n7066\t+\t\tcustomAttrs = workflowData.ParsedFrontmatter.Observability.OTLP.Attributes\n7067\t+\t}\n7068\t+\tif encoded := encodeOTLPCustomAttributes(customAttrs); encoded != \"\" {\n7069\t+\t\tescapedEncoded := strings.ReplaceAll(encoded, \"'\", \"''\")\n7070\t+\t\totlpEnvLines += \"\\n  GH_AW_OTLP_ATTRIBUTES: '\" + escapedEncoded + \"'\"\n7071\t+\t\totlpLog.Printf(\"Injected GH_AW_OTLP_ATTRIBUTES env var (%d custom attributes)\", len(customAttrs))\n7072\t+\t}\n7073\t+\n7074\t \tif workflowData.Env == \"\" {\n7075\t \t\tworkflowData.Env = \"env:\\n\" + otlpEnvLines\n7076\t \t} else {\n7077\t@@ -503,7 +636,7 @@ func (c *Compiler) injectOTLPConfig(workflowData *WorkflowData) {\n7078\t }\n7079\t "}]},"parent_tool_use_id":null,"session_id":"61feb3cf-a80f-46c6-9b4d-4e7ec9167e3e","uuid":"015210df-e242-4912-b63f-baf078def17c","timestamp":"2026-05-21T23:46:12.017Z","tool_use_result":{"type":"text","file":{"filePath":"/tmp/gh-aw/agent/pr.diff","content":"diff --git a/actions/setup/sh/mask_otlp_attributes.sh b/actions/setup/sh/mask_otlp_attributes.sh\nnew file mode 100755\nindex 00000000000..8b83cfc4f26\n--- /dev/null\n+++ b/actions/setup/sh/mask_otlp_attributes.sh\n@@ -0,0 +1,53 @@\n+#!/usr/bin/env bash\n+set +o histexpand\n+\n+#\n+# mask_otlp_attributes.sh - Mask GH_AW_OTLP_ATTRIBUTES values from GitHub Actions logs\n+#\n+# Issues the ::add-mask:: workflow command for every value found in the\n+# GH_AW_OTLP_ATTRIBUTES JSON object so that user-supplied custom OTLP span\n+# attribute values (e.g. session IDs, user IDs) do not leak into GitHub\n+# Actions runner logs (including debug/step-debug logs).\n+#\n+# GH_AW_OTLP_ATTRIBUTES is a JSON-encoded Record<string, string> injected by\n+# the gh-aw compiler from the `observability.otlp.attributes` frontmatter field.\n+# Each value is masked individually; empty values are skipped.\n+#\n+# Requires node to be available on PATH (it is always present on GitHub Actions\n+# runners when the gh-aw setup step has run).\n+#\n+# Exit codes:\n+#   0 - Success (variable may be absent or empty, which is a no-op)\n+\n+set -euo pipefail\n+\n+_attrs=\"${GH_AW_OTLP_ATTRIBUTES:-}\"\n+[ -z \"$_attrs\" ] && exit 0\n+\n+# Use node to extract the string values from the JSON object and print one\n+# per line (null/empty values are omitted).\n+_GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node)\n+\n+# Read the values into an array, then issue ::add-mask:: for each non-empty one.\n+mapfile -t _values < <(\n+  printf '%s' \"$_attrs\" | \"$_GH_AW_NODE\" -e '\n+    let raw = \"\";\n+    process.stdin.on(\"data\", d => { raw += d; });\n+    process.stdin.on(\"end\", () => {\n+      try {\n+        const obj = JSON.parse(raw);\n+        if (obj !== null && typeof obj === \"object\" && !Array.isArray(obj)) {\n+          for (const v of Object.values(obj)) {\n+            if (typeof v === \"string\" && v.length > 0) {\n+              process.stdout.write(v + \"\\n\");\n+            }\n+          }\n+        }\n+      } catch { /* invalid JSON – no-op */ }\n+    });\n+  '\n+)\n+\n+for _val in \"${_values[@]}\"; do\n+  [ -n \"$_val\" ] && echo '::add-mask::'\"$_val\"\n+done\ndiff --git a/pkg/parser/import_field_extractor.go b/pkg/parser/import_field_extractor.go\nindex 1a7907bfe84..52f479bd420 100644\n--- a/pkg/parser/import_field_extractor.go\n+++ b/pkg/parser/import_field_extractor.go\n@@ -770,11 +770,13 @@ func extractOTLPEndpointsFromObsMap(obs map[string]any) []observabilityImportEnd\n // mergeObservabilityConfigs takes a slice of observability config JSON strings (one per\n // import), extracts all OTLP endpoint entries from each (supporting string, object, and\n // array forms), deduplicates by URL (first occurrence wins), and returns a single merged\n-// observability JSON string with all endpoints expressed as an array.\n-// Returns \"\" when no valid endpoints are found.\n+// observability JSON string with all endpoints expressed as an array.  Custom OTLP\n+// attributes are also merged across imports (first occurrence wins per key).\n+// Returns \"\" when no valid endpoints or attributes are found.\n func mergeObservabilityConfigs(configs []string) string {\n \tseen := make(map[string]bool)\n \tvar allEndpoints []observabilityImportEndpoint\n+\tmergedAttrs := make(map[string]string)\n \n \tfor i, cfgJSON := range configs {\n \t\tif cfgJSON == \"\" {\n@@ -791,19 +793,28 @@ func mergeObservabilityConfigs(configs []string) string {\n \t\t\t\tallEndpoints = append(allEndpoints, e)\n \t\t\t}\n \t\t}\n+\t\tfor k, v := range extractOTLPAttributesFromObsMap(obs) {\n+\t\t\tif _, exists := mergedAttrs[k]; !exists {\n+\t\t\t\tmergedAttrs[k] = v\n+\t\t\t}\n+\t\t}\n \t}\n \n-\tif len(allEndpoints) == 0 {\n+\tif len(allEndpoints) == 0 && len(mergedAttrs) == 0 {\n \t\treturn \"\"\n \t}\n \n \t// Produce a merged config with the endpoint field as an array so that the\n-\t// workflow package's collectAllOTLPEndpoints handles it uniformly.\n-\tmerged := map[string]any{\n-\t\t\"otlp\": map[string]any{\n-\t\t\t\"endpoint\": allEndpoints,\n-\t\t},\n+\t// workflow package's collectAllOTLPEndpoints handles it uniformly.  Include\n+\t// any merged custom attributes so the orchestrator can propagate them.\n+\totlpMap := map[string]any{}\n+\tif len(allEndpoints) > 0 {\n+\t\totlpMap[\"endpoint\"] = allEndpoints\n \t}\n+\tif len(mergedAttrs) > 0 {\n+\t\totlpMap[\"attributes\"] = mergedAttrs\n+\t}\n+\tmerged := map[string]any{\"otlp\": otlpMap}\n \tb, err := json.Marshal(merged)\n \tif err != nil {\n \t\tparserLog.Printf(\"Failed to marshal %d merged OTLP endpoints: %v\", len(allEndpoints), err)\n@@ -812,6 +823,44 @@ func mergeObservabilityConfigs(configs []string) string {\n \treturn string(b)\n }\n \n+// extractOTLPAttributesFromObsMap reads the custom OTLP attributes map from a\n+// raw observability section (as parsed from an import's frontmatter).  Only\n+// string values are accepted; non-string values are silently ignored.\n+// Returns nil when the field is absent or empty.\n+//\n+// Note: this intentionally duplicates the logic of\n+// workflow.extractOTLPCustomAttributesFromObsMap.  The parser package must not\n+// import the workflow package (circular-dependency risk), so the helper lives\n+// here as a local copy.  Both implementations must stay in sync.\n+func extractOTLPAttributesFromObsMap(obs map[string]any) map[string]string {\n+\tif obs == nil {\n+\t\treturn nil\n+\t}\n+\totlpAny, ok := obs[\"otlp\"]\n+\tif !ok {\n+\t\treturn nil\n+\t}\n+\totlpMap, ok := otlpAny.(map[string]any)\n+\tif !ok {\n+\t\treturn nil\n+\t}\n+\tattrsAny, ok := otlpMap[\"attributes\"]\n+\tif !ok {\n+\t\treturn nil\n+\t}\n+\tattrsMap, ok := attrsAny.(map[string]any)\n+\tif !ok {\n+\t\treturn nil\n+\t}\n+\tresult := make(map[string]string, len(attrsMap))\n+\tfor k, v := range attrsMap {\n+\t\tif s, ok := v.(string); ok && k != \"\" {\n+\t\t\tresult[k] = s\n+\t\t}\n+\t}\n+\treturn result\n+}\n+\n // suitable for use in a {{#runtime-import ...}} macro.\n //\n // The rules are:\ndiff --git a/pkg/workflow/compiler_activation_job_builder.go b/pkg/workflow/compiler_activation_job_builder.go\nindex f03bc8aa805..2659b5bc9c4 100644\n--- a/pkg/workflow/compiler_activation_job_builder.go\n+++ b/pkg/workflow/compiler_activation_job_builder.go\n@@ -111,6 +111,9 @@ func (c *Compiler) newActivationJobBuildContext(\n \tif isOTLPHeadersPresent(data) {\n \t\tctx.steps = append(ctx.steps, generateOTLPHeadersMaskStep())\n \t}\n+\tif isOTLPAttributesPresent(data) {\n+\t\tctx.steps = append(ctx.steps, generateOTLPAttributesMaskStep())\n+\t}\n \tif hasWorkflowCallTrigger(data.On) && !data.InlinedImports {\n \t\tcompilerActivationJobLog.Print(\"Adding resolve-host-repo step for workflow_call trigger\")\n \t\tctx.steps = append(ctx.steps, c.generateResolveHostRepoStep(data))\ndiff --git a/pkg/workflow/compiler_orchestrator_workflow.go b/pkg/workflow/compiler_orchestrator_workflow.go\nindex 784d0c7cf16..4ba0001db02 100644\n--- a/pkg/workflow/compiler_orchestrator_workflow.go\n+++ b/pkg/workflow/compiler_orchestrator_workflow.go\n@@ -175,10 +175,11 @@ func (c *Compiler) ParseWorkflowFile(markdownPath string) (*WorkflowData, error)\n \t\treturn nil, formatCompilerError(cleanPath, \"error\", err.Error(), err)\n \t}\n \n-\t// Merge observability endpoints from imports with those from the main workflow.\n-\t// All OTLP endpoints from both sources are combined into an array, deduplicating\n-\t// by URL (main workflow endpoints take precedence). This allows multiple shared\n-\t// workflows each defining their own OTLP endpoint to fan out to all collectors.\n+\t// Merge observability endpoints and custom attributes from imports with those\n+\t// from the main workflow.  All OTLP endpoints from both sources are combined\n+\t// into an array, deduplicating by URL (main workflow endpoints take precedence).\n+\t// Custom attributes follow the same precedence: main workflow values override\n+\t// imported ones for the same key.\n \tif obs := engineSetup.importsResult.MergedObservability; obs != \"\" {\n \t\tvar importedObs map[string]any\n \t\tif err := json.Unmarshal([]byte(obs), &importedObs); err == nil {\n@@ -207,14 +208,29 @@ func (c *Compiler) ParseWorkflowFile(markdownPath string) (*WorkflowData, error)\n \t\t\t\t}\n \t\t\t}\n \n-\t\t\tif len(mergedEndpoints) > 0 {\n+\t\t\t// Merge custom OTLP attributes: import attrs provide defaults, main\n+\t\t\t// workflow attrs override them.\n+\t\t\tmainAttrs := extractOTLPCustomAttributesFromObsMap(mainObs)\n+\t\t\timportAttrs := extractOTLPCustomAttributesFromObsMap(importedObs)\n+\t\t\t// mergeOTLPCustomAttributes(base, override) β€” base wins, so pass main as base.\n+\t\t\tmergedAttrs := mergeOTLPCustomAttributes(mainAttrs, importAttrs)\n+\n+\t\t\tif len(mergedEndpoints) > 0 || len(mergedAttrs) > 0 {\n \t\t\t\tmainCount := len(mergedEndpoints) - importAdded\n+\t\t\t\tnewOTLP := map[string]any{}\n+\t\t\t\tif len(mergedEndpoints) > 0 {\n+\t\t\t\t\tnewOTLP[\"endpoint\"] = mergedEndpoints\n+\t\t\t\t}\n+\t\t\t\tif len(mergedAttrs) > 0 {\n+\t\t\t\t\tnewOTLP[\"attributes\"] = mergedAttrs\n+\t\t\t\t}\n \t\t\t\tworkflowData.RawFrontmatter[\"observability\"] = map[string]any{\n-\t\t\t\t\t\"otlp\": map[string]any{\n-\t\t\t\t\t\t\"endpoint\": mergedEndpoints,\n-\t\t\t\t\t},\n+\t\t\t\t\t\"otlp\": newOTLP,\n \t\t\t\t}\n \t\t\t\torchestratorWorkflowLog.Printf(\"Merged OTLP endpoints into RawFrontmatter: %d from main workflow, %d from imports (%d total)\", mainCount, importAdded, len(mergedEndpoints))\n+\t\t\t\tif len(mergedAttrs) > 0 {\n+\t\t\t\t\torchestratorWorkflowLog.Printf(\"Merged %d custom OTLP attributes into RawFrontmatter\", len(mergedAttrs))\n+\t\t\t\t}\n \t\t\t}\n \t\t}\n \t}\ndiff --git a/pkg/workflow/compiler_safe_outputs_job.go b/pkg/workflow/compiler_safe_outputs_job.go\nindex 319c7c0a774..597a20a2f59 100644\n--- a/pkg/workflow/compiler_safe_outputs_job.go\n+++ b/pkg/workflow/compiler_safe_outputs_job.go\n@@ -97,6 +97,10 @@ func (c *Compiler) buildSafeOutputsSetupAndDownloadSteps(data *WorkflowData, age\n \tif isOTLPHeadersPresent(data) {\n \t\tsteps = append(steps, generateOTLPHeadersMaskStep())\n \t}\n+\t// Mask custom OTLP attribute values so user-supplied values cannot leak into runner logs.\n+\tif isOTLPAttributesPresent(data) {\n+\t\tsteps = append(steps, generateOTLPAttributesMaskStep())\n+\t}\n \n \t// Add artifact download steps after setup.\n \t// In workflow_call context, use the per-invocation prefix to avoid artifact name clashes.\ndiff --git a/pkg/workflow/compiler_yaml_main_job.go b/pkg/workflow/compiler_yaml_main_job.go\nindex f2d3489bc3c..a9c57c5f1d3 100644\n--- a/pkg/workflow/compiler_yaml_main_job.go\n+++ b/pkg/workflow/compiler_yaml_main_job.go\n@@ -59,6 +59,10 @@ func (c *Compiler) generateInitialAndCheckoutSteps(yaml *strings.Builder, data *\n \tif isOTLPHeadersPresent(data) {\n \t\tyaml.WriteString(generateOTLPHeadersMaskStep())\n \t}\n+\t// Mask custom OTLP attribute values so user-supplied values cannot leak into runner logs.\n+\tif isOTLPAttributesPresent(data) {\n+\t\tyaml.WriteString(generateOTLPAttributesMaskStep())\n+\t}\n \n \t// Add pre-steps before checkout and the subsequent built-in steps in this agent job.\n \t// This allows users to mint short-lived tokens (via custom actions) in the same\ndiff --git a/pkg/workflow/frontmatter_types.go b/pkg/workflow/frontmatter_types.go\nindex 7e97f6b3037..c542d9f97c5 100644\n--- a/pkg/workflow/frontmatter_types.go\n+++ b/pkg/workflow/frontmatter_types.go\n@@ -235,6 +235,20 @@ type OTLPConfig struct {\n \t// This setting affects MCP gateway setup only. Other OTLP-aware steps still\n \t// receive workflow-level OTEL_* environment variables.\n \tIfMissing string `json:\"if-missing,omitempty\"`\n+\n+\t// Attributes defines additional custom key-value string attributes to attach\n+\t// to every OTLP span emitted by this workflow (setup, agent, and conclusion).\n+\t// Values support template variables using {{ variable }} syntax, where the\n+\t// variable name is any OTLP attribute key already computed for the span\n+\t// (e.g. {{ github.aw.episode.id }}, {{ github.actor }}).\n+\t//\n+\t// Example – emit Langfuse session/user attributes alongside the standard ones:\n+\t//   attributes:\n+\t//     langfuse.session.id: \"{{ github.aw.episode.id }}\"\n+\t//     session.id:          \"{{ github.aw.episode.id }}\"\n+\t//     langfuse.user.id:    \"{{ github.actor }}\"\n+\t//     user.id:             \"{{ github.actor }}\"\n+\tAttributes map[string]string `json:\"attributes,omitempty\"`\n }\n \n // ObservabilityConfig represents workflow observability options.\ndiff --git a/pkg/workflow/observability_otlp.go b/pkg/workflow/observability_otlp.go\nindex 4c6eb20e7fe..f8bb3b824d0 100644\n--- a/pkg/workflow/observability_otlp.go\n+++ b/pkg/workflow/observability_otlp.go\n@@ -240,6 +240,29 @@ func generateOTLPHeadersMaskStep() string {\n \treturn sb.String()\n }\n \n+// isOTLPAttributesPresent returns true when GH_AW_OTLP_ATTRIBUTES has been\n+// injected into the workflow-level env block.  This indicates that attribute\n+// value masking is needed so that user-supplied values do not leak into\n+// GitHub Actions runner logs.\n+func isOTLPAttributesPresent(data *WorkflowData) bool {\n+\tif data == nil {\n+\t\treturn false\n+\t}\n+\treturn strings.Contains(data.Env, \"GH_AW_OTLP_ATTRIBUTES\")\n+}\n+\n+// generateOTLPAttributesMaskStep returns a GitHub Actions step that runs\n+// mask_otlp_attributes.sh to issue the ::add-mask:: workflow command for every\n+// value in the GH_AW_OTLP_ATTRIBUTES JSON object.  Masking the values prevents\n+// user-supplied custom span attribute values (e.g. session IDs, user IDs) from\n+// appearing in plaintext in GitHub Actions runner logs.\n+func generateOTLPAttributesMaskStep() string {\n+\tvar sb strings.Builder\n+\tsb.WriteString(\"      - name: Mask OTLP custom attribute values\\n\")\n+\tsb.WriteString(\"        run: bash \\\"${RUNNER_TEMP}/gh-aw/actions/mask_otlp_attributes.sh\\\"\\n\")\n+\treturn sb.String()\n+}\n+\n // otlpEndpointEntry is the wire format used when encoding the GH_AW_OTLP_ENDPOINTS\n // environment variable as a JSON array.  Each entry carries the endpoint URL and\n // its optional normalized (comma-separated key=value) headers string.\n@@ -248,6 +271,96 @@ type otlpEndpointEntry struct {\n \tHeaders string `json:\"headers,omitempty\"`\n }\n \n+// collectOTLPCustomAttributes reads the `observability.otlp.attributes` map from\n+// a raw frontmatter map and returns it as a map[string]string.  Only string values\n+// are accepted; non-string values are silently ignored.  Returns nil when the\n+// field is absent or empty.\n+func collectOTLPCustomAttributes(frontmatter map[string]any) map[string]string {\n+\tif frontmatter == nil {\n+\t\treturn nil\n+\t}\n+\tobsAny, ok := frontmatter[\"observability\"]\n+\tif !ok {\n+\t\treturn nil\n+\t}\n+\tobsMap, ok := obsAny.(map[string]any)\n+\tif !ok {\n+\t\treturn nil\n+\t}\n+\treturn extractOTLPCustomAttributesFromObsMap(obsMap)\n+}\n+\n+// extractOTLPCustomAttributesFromObsMap reads the `otlp.attributes` map from\n+// a raw observability section map (i.e. the value of the \"observability\" key in\n+// the frontmatter) and returns it as a map[string]string.  Only string values are\n+// accepted; non-string values are silently ignored.  Returns nil when the field is\n+// absent or empty.\n+func extractOTLPCustomAttributesFromObsMap(obsMap map[string]any) map[string]string {\n+\tif obsMap == nil {\n+\t\treturn nil\n+\t}\n+\totlpAny, ok := obsMap[\"otlp\"]\n+\tif !ok {\n+\t\treturn nil\n+\t}\n+\totlpMap, ok := otlpAny.(map[string]any)\n+\tif !ok {\n+\t\treturn nil\n+\t}\n+\tattrsAny, ok := otlpMap[\"attributes\"]\n+\tif !ok {\n+\t\treturn nil\n+\t}\n+\tattrsMap, ok := attrsAny.(map[string]any)\n+\tif !ok {\n+\t\treturn nil\n+\t}\n+\tresult := make(map[string]string, len(attrsMap))\n+\tfor k, v := range attrsMap {\n+\t\tif s, ok := v.(string); ok && k != \"\" {\n+\t\t\tresult[k] = s\n+\t\t}\n+\t}\n+\tif len(result) == 0 {\n+\t\treturn nil\n+\t}\n+\treturn result\n+}\n+\n+// encodeOTLPCustomAttributes serialises a map[string]string of custom OTLP span\n+// attributes to a compact JSON string suitable for use as the GH_AW_OTLP_ATTRIBUTES\n+// environment variable.  Returns an empty string when the map is nil/empty or\n+// serialisation fails.\n+func encodeOTLPCustomAttributes(attrs map[string]string) string {\n+\tif len(attrs) == 0 {\n+\t\treturn \"\"\n+\t}\n+\tb, err := json.Marshal(attrs)\n+\tif err != nil {\n+\t\totlpLog.Printf(\"Failed to encode OTLP custom attributes: %v\", err)\n+\t\treturn \"\"\n+\t}\n+\treturn string(b)\n+}\n+\n+// mergeOTLPCustomAttributes merges two custom-attribute maps; values in base\n+// take precedence over values in override when the same key is present in both.\n+// Returns nil when both inputs are empty.\n+func mergeOTLPCustomAttributes(base, override map[string]string) map[string]string {\n+\tif len(base) == 0 && len(override) == 0 {\n+\t\treturn nil\n+\t}\n+\tmerged := make(map[string]string, len(base)+len(override))\n+\tfor k, v := range override {\n+\t\tmerged[k] = v\n+\t}\n+\t// base takes precedence\n+\tfor k, v := range base {\n+\t\tmerged[k] = v\n+\t}\n+\treturn merged\n+}\n+\n // collectAllOTLPEndpoints reads the `observability.otlp.endpoint` field from the raw\n // frontmatter and returns all configured endpoint entries. The `endpoint` field may be:\n //\n@@ -418,6 +531,10 @@ func allOTLPHeaders(entries []otlpEndpointEntry) string {\n //     injected for the first endpoint (backward compat) and GH_AW_OTLP_ALL_HEADERS\n //     is injected with all headers across every endpoint (for secret masking).\n //\n+//  5. When observability.otlp.attributes is configured, GH_AW_OTLP_ATTRIBUTES is\n+//     injected as a JSON-encoded map so that span-emitting scripts can append custom\n+//     attributes (including Langfuse session/user IDs) to every span.\n+//\n // When no OTLP endpoint is configured the function is a no-op.\n func (c *Compiler) injectOTLPConfig(workflowData *WorkflowData) {\n \t// Collect all endpoint entries from the endpoint field (string, object, or array).\n@@ -488,6 +605,22 @@ func (c *Compiler) injectOTLPConfig(workflowData *WorkflowData) {\n \t\totlpLog.Printf(\"Injected GH_AW_OTLP_IF_MISSING env var (%s)\", ifMissingMode)\n \t}\n \n+\t// 5. Inject GH_AW_OTLP_ATTRIBUTES (JSON object) for custom per-span attributes.\n+\t//    Attributes from RawFrontmatter take precedence; ParsedFrontmatter is the\n+\t//    fallback for workflows that were parsed but whose RawFrontmatter was later\n+\t//    modified (e.g. during observability merge in the orchestrator).\n+\tcustomAttrs := collectOTLPCustomAttributes(workflowData.RawFrontmatter)\n+\tif len(customAttrs) == 0 && workflowData.ParsedFrontmatter != nil &&\n+\t\tworkflowData.ParsedFrontmatter.Observability != nil &&\n+\t\tworkflowData.ParsedFrontmatter.Observability.OTLP != nil {\n+\t\tcustomAttrs = workflowData.ParsedFrontmatter.Observability.OTLP.Attributes\n+\t}\n+\tif encoded := encodeOTLPCustomAttributes(customAttrs); encoded != \"\" {\n+\t\tescapedEncoded := strings.ReplaceAll(encoded, \"'\", \"''\")\n+\t\totlpEnvLines += \"\\n  GH_AW_OTLP_ATTRIBUTES: '\" + escapedEncoded + \"'\"\n+\t\totlpLog.Printf(\"Injected GH_AW_OTLP_ATTRIBUTES env var (%d custom attributes)\", len(customAttrs))\n+\t}\n+\n \tif workflowData.Env == \"\" {\n \t\tworkflowData.Env = \"env:\\n\" + otlpEnvLines\n \t} else {\n@@ -503,7 +636,7 @@ func (c *Compiler) injectOTLPConfig(workflowData *WorkflowData) {\n }\n ","numLines":450,"startLine":6630,"totalLines":7568}}}
{"type":"result","subtype":"error_max_turns","duration_ms":71999,"duration_api_ms":73700,"is_error":true,"num_turns":21,"stop_reason":"tool_use","session_id":"61feb3cf-a80f-46c6-9b4d-4e7ec9167e3e","total_cost_usd":1.2212945,"usage":{"input_tokens":24,"cache_creation_input_tokens":73224,"cache_read_input_tokens":1199669,"output_tokens":3613,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":73224},"inference_geo":"","iterations":[],"speed":"standard"},"modelUsage":{"claude-opus-4-7[1m]":{"inputTokens":14582,"outputTokens":3636,"cacheReadInputTokens":1199669,"cacheCreationInputTokens":73224,"webSearchRequests":0,"costUSD":1.2212945,"contextWindow":1000000,"maxOutputTokens":64000}},"permission_denials":[{"tool_name":"Bash","tool_use_id":"toolu_01W6XTZt61rh6KSnBzAtCA1N","tool_input":{"command":"wc -l /tmp/gh-aw/agent/pr.diff /tmp/gh-aw/agent/pr-files.json 2>&1","description":"Check sizes of pre-fetched files"}},{"tool_name":"Bash","tool_use_id":"toolu_018vLmsxq6UKTBrtdtrYEYjh","tool_input":{"command":"grep -E \"^diff --git\" /home/runner/work/gh-aw/gh-aw/.git/FETCH_HEAD 2>/dev/null | head -5; ls -la /tmp/gh-aw/agent/ 2>&1","description":"Check available agent files"}},{"tool_name":"Bash","tool_use_id":"toolu_01QfBcGd78y63tTAzXXLpEDr","tool_input":{"command":"ls -la /tmp/gh-aw/agent/","description":"List pre-fetched agent files"}}],"terminal_reason":"max_turns","fast_mode_state":"off","uuid":"3236de0f-dbc9-4d98-a89b-b343d1a735d4","errors":["Reached maximum number of turns (20)"]}
2026-05-21T23:46:12.022Z [DEBUG] LSP server manager shut down successfully
2026-05-21T23:46:12.022Z [DEBUG] Cleaned up session snapshot: /home/runner/.claude/shell-snapshots/snapshot-bash-1779407111317-uak3a9.sh

Action Required

Assign this issue to an agent to debug and fix the issue.

Debug with any coding agent

Use this prompt with any coding agent (GitHub Copilot, Claude, Gemini, etc.):

Debug the agentic workflow failure using https://raw.githubusercontent.com/github/gh-aw/main/debug.md

The failed workflow run is at https://github.com/github/gh-aw/actions/runs/26259479701
Manually invoke the agent

Debug this workflow failure using your favorite Agent CLI and the agentic-workflows prompt.

Tip

Stop reporting this workflow as a failure

To stop a workflow from creating failure issues, set report-failure-as-issue: false in its frontmatter:

safe-outputs:
  report-failure-as-issue: false

Generated from Design Decision Gate πŸ—οΈ Β· ● 5.6M Β· β—·

  • expires on May 22, 2026, 11:46 AM UTC

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions