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
24 changes: 24 additions & 0 deletions pkg/parser/schema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1170,6 +1170,30 @@ func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_OTLPGitHubAppImpli
}
}

func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_OTLPResourceAttributes(t *testing.T) {
frontmatter := map[string]any{
"name": "OTLP resource attributes config",
"on": map[string]any{
"issues": map[string]any{
"types": []any{"opened"},
},
},
"observability": map[string]any{
"otlp": map[string]any{
"resource-attributes": map[string]any{
"my.target-repo": "${{ github.repository }}",
"my.event": "${{ github.event_name }}",
},
},
},
}

err := ValidateMainWorkflowFrontmatterWithSchemaAndLocation(frontmatter, "/tmp/gh-aw/otlp-resource-attributes-schema-test.md")
if err != nil {
t.Fatalf("expected observability.otlp.resource-attributes to pass schema validation, got: %v", err)
}
}

func TestValidateMainWorkflowFrontmatterWithSchemaAndLocation_OTLPGitHubAppAudienceRejected(t *testing.T) {
frontmatter := map[string]any{
"name": "OTLP github-app audience rejection",
Expand Down
7 changes: 7 additions & 0 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -10601,6 +10601,13 @@
"default": "error",
"description": "How to handle missing OTLP endpoint/header values at runtime (for example from unset secrets). 'error' fails workflow startup (default), 'warn' logs a warning and skips MCP gateway OTLP configuration, and 'ignore' skips MCP gateway OTLP configuration without warning. This affects MCP gateway setup only; workflow-level OTEL_* environment variables are still injected."
},
"resource-attributes": {
"type": "object",
"description": "Additional OTEL_RESOURCE_ATTRIBUTES entries to append to the standard gh-aw/GitHub resource attributes. Values may be static strings or GitHub Actions expressions such as '${{ github.repository }}'. Do not use secrets.* or vars.* expressions here: resource attributes are exported to external tracing backends and are not treated as secret values.",
"additionalProperties": {
"type": "string"
}
},
"github-app": {
"description": "Optional runtime authentication for OTLP export. Supports GitHub App credentials (client-id/app-id + private-key) for token minting, or implicit GitHub OIDC mode when the github-app object is present without credentials.",
"type": "object",
Expand Down
25 changes: 22 additions & 3 deletions pkg/workflow/compiler_orchestrator_workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -248,15 +248,27 @@ func (c *Compiler) mergeImportedObservability(workflowData *WorkflowData, merged
}
mainObs := extractRawObservabilityMap(workflowData.RawFrontmatter)
mergedEndpoints, mainCount, importAdded := mergeRawOTLPEndpoints(mainObs, importedObs)
mergedAttrs := mergeOTLPCustomAttributes(
mergedAttrs := mergeOTLPStringMaps(
extractOTLPCustomAttributesFromObsMap(mainObs),
extractOTLPCustomAttributesFromObsMap(importedObs),
)
mergedResourceAttrs := mergeOTLPStringMaps(
extractOTLPResourceAttributesFromObsMap(mainObs),
extractOTLPResourceAttributesFromObsMap(importedObs),
)
githubApp := extractRawOTLPGitHubAppMap(mainObs)
if githubApp == nil {
githubApp = extractRawOTLPGitHubAppMap(importedObs)
}
applyMergedRawObservability(workflowData.RawFrontmatter, mergedEndpoints, mergedAttrs, githubApp, mainCount, importAdded)
applyMergedRawObservability(
workflowData.RawFrontmatter,
mergedEndpoints,
mergedAttrs,
mergedResourceAttrs,
githubApp,
mainCount,
importAdded,
)
}

func extractRawObservabilityMap(rawFrontmatter map[string]any) map[string]any {
Expand Down Expand Up @@ -290,11 +302,12 @@ func applyMergedRawObservability(
rawFrontmatter map[string]any,
mergedEndpoints []any,
mergedAttrs map[string]string,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

@copilot check that the next feature is not just a duplicate of mergeAttrs

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed — mergeOTLPCustomAttributes was indeed just a one-line wrapper around mergeOTLPStringMaps and added no value. Removed it and updated both the mergeImportedObservability call site and the test to use mergeOTLPStringMaps directly, so both span-attribute and resource-attribute merges now use the same function consistently (commit Remove redundant mergeOTLPCustomAttributes wrapper).

mergedResourceAttrs map[string]string,
githubApp map[string]any,
mainCount int,
importAdded int,
) {
if len(mergedEndpoints) == 0 && len(mergedAttrs) == 0 && githubApp == nil {
if len(mergedEndpoints) == 0 && len(mergedAttrs) == 0 && len(mergedResourceAttrs) == 0 && githubApp == nil {
return
}
newOTLP := map[string]any{}
Expand All @@ -304,6 +317,9 @@ func applyMergedRawObservability(
if len(mergedAttrs) > 0 {
newOTLP["attributes"] = mergedAttrs
}
if len(mergedResourceAttrs) > 0 {
newOTLP["resource-attributes"] = mergedResourceAttrs
}
if githubApp != nil {
newOTLP["github-app"] = githubApp
}
Expand All @@ -312,6 +328,9 @@ func applyMergedRawObservability(
if len(mergedAttrs) > 0 {
orchestratorWorkflowLog.Printf("Merged %d custom OTLP attributes into RawFrontmatter", len(mergedAttrs))
}
if len(mergedResourceAttrs) > 0 {
orchestratorWorkflowLog.Printf("Merged %d OTLP resource attributes into RawFrontmatter", len(mergedResourceAttrs))
}
}

func (c *Compiler) mergeWorkflowEnv(frontmatter map[string]any, workflowData *WorkflowData, importsResult *parser.ImportsResult) error {
Expand Down
36 changes: 36 additions & 0 deletions pkg/workflow/compiler_orchestrator_workflow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
package workflow

import (
"encoding/json"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -350,6 +351,41 @@ func TestMergeRawOTLPEndpoints_DedupesAndCountsSources(t *testing.T) {
assert.Equal(t, "https://import.example/otlp", mergedEndpoints[2].(map[string]any)["url"])
}

func TestMergeImportedObservability_MergesResourceAttributesWithMainPrecedence(t *testing.T) {
importedObsJSON, err := json.Marshal(map[string]any{
"otlp": map[string]any{
"resource-attributes": map[string]any{
"shared.key": "from-import",
"import.only.key": "import-value",
},
},
})
require.NoError(t, err)

workflowData := &WorkflowData{
RawFrontmatter: map[string]any{
"observability": map[string]any{
"otlp": map[string]any{
"resource-attributes": map[string]any{
"shared.key": "from-main",
"main.only.key": "main-value",
},
},
},
},
}

NewCompiler().mergeImportedObservability(workflowData, string(importedObsJSON))

obs := workflowData.RawFrontmatter["observability"].(map[string]any)
otlp := obs["otlp"].(map[string]any)
assert.Equal(t, map[string]string{
"shared.key": "from-main",
"main.only.key": "main-value",
"import.only.key": "import-value",
}, otlp["resource-attributes"])
}

func TestBuildMergedEnvSources_MainWorkflowWins(t *testing.T) {
mergedEnv := map[string]any{
"MAIN_ONLY": "1",
Expand Down
1 change: 1 addition & 0 deletions pkg/workflow/compiler_validators.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ func (c *Compiler) validateCoreToolConfiguration(workflowData *WorkflowData, mar
{logMessage: "Validating network allowed domains", validateFn: func() error { return c.validateNetworkAllowedDomains(workflowData.NetworkPermissions) }},
{logMessage: "Validating network firewall configuration", validateFn: func() error { return validateNetworkFirewallConfig(workflowData.NetworkPermissions) }},
{logMessage: "Validating safe-outputs allow-workflows", validateFn: func() error { return validateSafeOutputsAllowWorkflows(workflowData.SafeOutputs) }},
{logMessage: "Validating OTLP resource attributes", validateFn: func() error { return validateOTLPResourceAttributes(workflowData) }},
{logMessage: "Validating labels", validateFn: func() error { return validateLabels(workflowData) }},
{logMessage: "Validating workflow_dispatch input requirements for command triggers", validateFn: func() error { return validateCommandWorkflowDispatchInputs(workflowData) }},
{logMessage: "Validating max-daily-ai-credits frontmatter", validateFn: func() error { return validateMaxDailyAICFrontmatter(workflowData) }},
Expand Down
9 changes: 9 additions & 0 deletions pkg/workflow/frontmatter_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,15 @@ type OTLPConfig struct {
// user.id: "{{ github.actor }}"
Attributes map[string]string `json:"attributes,omitempty"`

// ResourceAttributes defines additional OTEL_RESOURCE_ATTRIBUTES entries to
// append to the standard gh-aw/GitHub resource attributes.
//
// Values may be static strings or GitHub Actions expressions such as
// ${{ github.repository }}. Do not use secrets.* or vars.* expressions here:
// resource attributes are exported to external tracing backends and are not
// treated as secret values.
ResourceAttributes map[string]string `json:"resource-attributes,omitempty"`

// GitHubApp configures runtime OTLP authentication via the `github-app` key.
// Supported values:
// github-app:
Expand Down
117 changes: 98 additions & 19 deletions pkg/workflow/observability_otlp.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
var otlpLog = logger.New("workflow:observability_otlp")

var sentryEndpointExpressionPattern = regexp.MustCompile(`(?i)^\$\{\{\s*secrets\.` + regexp.QuoteMeta(constants.OTELSentryEndpointSecretName) + `\s*\}\}$`)
var otlpResourceAttributeSecretRefPattern = regexp.MustCompile(`\$\{\{\s*(secrets|vars)\.`)

func normalizeOTLPHeadersForEndpoint(raw any, endpoint string) string {
if raw == nil {
Expand Down Expand Up @@ -324,6 +325,32 @@ func isOTLPAttributesPresent(data *WorkflowData) bool {
return strings.Contains(data.Env, "GH_AW_OTLP_ATTRIBUTES")
}

func getOTLPResourceAttributes(workflowData *WorkflowData) map[string]string {
if workflowData == nil {
return nil
}
resourceAttrs := collectOTLPResourceAttributes(workflowData.RawFrontmatter)
if len(resourceAttrs) == 0 &&
workflowData.ParsedFrontmatter != nil &&
workflowData.ParsedFrontmatter.Observability != nil &&
workflowData.ParsedFrontmatter.Observability.OTLP != nil {
resourceAttrs = workflowData.ParsedFrontmatter.Observability.OTLP.ResourceAttributes
}
return resourceAttrs
}

func validateOTLPResourceAttributes(workflowData *WorkflowData) error {
for key, value := range getOTLPResourceAttributes(workflowData) {
if otlpResourceAttributeSecretRefPattern.MatchString(value) {
return fmt.Errorf(
"observability.otlp.resource-attributes.%s must not reference secrets.* or vars.*; OTEL resource attributes are exported to tracing backends and are not treated as secret values",
key,
)
}
}
return nil
}
Comment on lines +342 to +352

// generateOTLPAttributesMaskStep returns a GitHub Actions step that runs
// mask_otlp_attributes.sh to issue the ::add-mask:: workflow command for every
// value in the GH_AW_OTLP_ATTRIBUTES JSON object. Masking the values prevents
Expand All @@ -345,8 +372,8 @@ type otlpEndpointEntry struct {
}

// collectOTLPCustomAttributes reads the `observability.otlp.attributes` map from
// a raw frontmatter map and returns it as a map[string]string. Only string values
// are accepted; non-string values are silently ignored. Returns nil when the
// a raw frontmatter map and returns it as a map[string]string. Only string values
// are accepted; non-string values are silently ignored. Returns nil when the
// field is absent or empty.
func collectOTLPCustomAttributes(frontmatter map[string]any) map[string]string {
if frontmatter == nil {
Expand All @@ -360,15 +387,34 @@ func collectOTLPCustomAttributes(frontmatter map[string]any) map[string]string {
if !ok {
return nil
}
return extractOTLPCustomAttributesFromObsMap(obsMap)
return extractOTLPStringMapFromObsMap(obsMap, "attributes")
}

// collectOTLPResourceAttributes reads the
// `observability.otlp.resource-attributes` map from a raw frontmatter map and
// returns it as a map[string]string. Only string values are accepted; non-string
// values are silently ignored. Returns nil when the field is absent or empty.
func collectOTLPResourceAttributes(frontmatter map[string]any) map[string]string {
if frontmatter == nil {
return nil
}
obsAny, ok := frontmatter["observability"]
if !ok {
return nil
}
obsMap, ok := obsAny.(map[string]any)
if !ok {
return nil
}
return extractOTLPStringMapFromObsMap(obsMap, "resource-attributes")
}

// extractOTLPCustomAttributesFromObsMap reads the `otlp.attributes` map from
// a raw observability section map (i.e. the value of the "observability" key in
// the frontmatter) and returns it as a map[string]string. Only string values are
// accepted; non-string values are silently ignored. Returns nil when the field is
// extractOTLPStringMapFromObsMap reads an `otlp.<fieldName>` string map from a
// raw observability section map (i.e. the value of the "observability" key in
// the frontmatter) and returns it as a map[string]string. Only string values are
// accepted; non-string values are silently ignored. Returns nil when the field is
// absent or empty.
func extractOTLPCustomAttributesFromObsMap(obsMap map[string]any) map[string]string {
func extractOTLPStringMapFromObsMap(obsMap map[string]any, fieldName string) map[string]string {
if obsMap == nil {
return nil
}
Expand All @@ -380,7 +426,7 @@ func extractOTLPCustomAttributesFromObsMap(obsMap map[string]any) map[string]str
if !ok {
return nil
}
attrsAny, ok := otlpMap["attributes"]
attrsAny, ok := otlpMap[fieldName]
if !ok {
return nil
}
Expand All @@ -400,6 +446,19 @@ func extractOTLPCustomAttributesFromObsMap(obsMap map[string]any) map[string]str
return result
}

// extractOTLPCustomAttributesFromObsMap reads the `otlp.attributes` map from a
// raw observability section map and returns it as a map[string]string.
func extractOTLPCustomAttributesFromObsMap(obsMap map[string]any) map[string]string {
return extractOTLPStringMapFromObsMap(obsMap, "attributes")
}

// extractOTLPResourceAttributesFromObsMap reads the
// `otlp.resource-attributes` map from a raw observability section map and
// returns it as a map[string]string.
func extractOTLPResourceAttributesFromObsMap(obsMap map[string]any) map[string]string {
return extractOTLPStringMapFromObsMap(obsMap, "resource-attributes")
}

// encodeOTLPCustomAttributes serialises a map[string]string of custom OTLP span
// attributes to a compact JSON string suitable for use as the GH_AW_OTLP_ATTRIBUTES
// environment variable. Returns an empty string when the map is nil/empty or
Expand All @@ -416,10 +475,10 @@ func encodeOTLPCustomAttributes(attrs map[string]string) string {
return string(b)
}

// mergeOTLPCustomAttributes merges two custom-attribute maps; values in base
// take precedence over values in override when the same key is present in both.
// Returns nil when both inputs are empty.
func mergeOTLPCustomAttributes(base, override map[string]string) map[string]string {
// mergeOTLPStringMaps merges two string maps; values in base take precedence over
// values in override when the same key is present in both. Returns nil when both
// inputs are empty.
func mergeOTLPStringMaps(base, override map[string]string) map[string]string {
if len(base) == 0 && len(override) == 0 {
return nil
}
Expand Down Expand Up @@ -769,22 +828,42 @@ func encodeOTELResourceAttributeValue(value string) string {
return strings.ReplaceAll(url.QueryEscape(value), "+", "%20")
}

func formatOTELResourceAttribute(key, value string) string {
trimmedKey := strings.TrimSpace(key)
trimmedValue := strings.TrimSpace(value)
if strings.Contains(trimmedValue, "${{") {
return encodeOTELResourceAttributeValue(trimmedKey) + "=" + trimmedValue
}
return encodeOTELResourceAttributeValue(trimmedKey) + "=" + encodeOTELResourceAttributeValue(trimmedValue)
}
Comment on lines +831 to +838

func otelResourceAttributes(workflowData *WorkflowData) string {
workflowNameAttrValue := "unknown"
if workflowData != nil {
if workflowName := strings.TrimSpace(workflowData.Name); workflowName != "" {
workflowNameAttrValue = encodeOTELResourceAttributeValue(workflowName)
workflowNameAttrValue = workflowName
}
}

attrs := []string{
"gh-aw.workflow.name=" + workflowNameAttrValue,
"gh-aw.repository=${{ github.repository }}",
"gh-aw.run.id=${{ github.run_id }}",
"github.run_id=${{ github.run_id }}",
formatOTELResourceAttribute("gh-aw.workflow.name", workflowNameAttrValue),
formatOTELResourceAttribute("gh-aw.repository", "${{ github.repository }}"),
formatOTELResourceAttribute("gh-aw.run.id", "${{ github.run_id }}"),
formatOTELResourceAttribute("github.run_id", "${{ github.run_id }}"),
}
if engineID := ResolveEngineID(workflowData); engineID != "" {
attrs = append(attrs, "gh-aw.engine.id="+encodeOTELResourceAttributeValue(engineID))
attrs = append(attrs, formatOTELResourceAttribute("gh-aw.engine.id", engineID))
}
resourceAttrs := getOTLPResourceAttributes(workflowData)
if len(resourceAttrs) > 0 {
keys := make([]string, 0, len(resourceAttrs))
for key := range resourceAttrs {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
attrs = append(attrs, formatOTELResourceAttribute(key, resourceAttrs[key]))
}
}
Comment on lines +857 to 867
return strings.Join(attrs, ",")
}
Loading
Loading