Skip to content

Commit fa86b9d

Browse files
krzkoDylan-M
authored andcommitted
[receiver/github] add repository custom properties as resource attributes (open-telemetry#40878)
#### Description This PR enhances the GitHub receiver by adding support for custom properties from GitHub repositories as resource attributes. The implementation extracts all key-value pairs from the repository's `CustomProperties` map and adds them as resource attributes with the prefix `github.repository.custom_properties`. For example, if a repository has custom properties like `team_name: "open-telemetry"` and `environment: "development"`, they will be added as resource attributes: ``` github.repository.custom_properties.team_name = "open-telemetry" github.repository.custom_properties.environment = "development" ``` The implementation handles different value types (string, int, float, bool) appropriately and follows the OpenTelemetry convention for attribute naming, similar to how HTTP headers are handled. #### Link to tracking issue Fixes: TBD #### Testing Added unit tests for the new functionality in `model_test.go`. The tests verify: * String properties are correctly added as resource attributes * Different types (string, int, float, bool) are handled correctly * The "service_name" property is skipped (as it's handled separately) * Empty and nil maps are handled properly All tests are passing, confirming the implementation works as expected. #### Documentation TBD
1 parent aa97683 commit fa86b9d

File tree

6 files changed

+311
-0
lines changed

6 files changed

+311
-0
lines changed
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Use this changelog template to create an entry for release notes.
2+
3+
# One of 'breaking', 'deprecation', 'new_component', 'enhancement', 'bug_fix'
4+
change_type: enhancement
5+
6+
# The name of the component, or a single word describing the area of concern, (e.g. filelogreceiver)
7+
component: githubreceiver
8+
9+
# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`).
10+
note: Added the ability to convert custom repository properties to span attributes
11+
12+
# Mandatory: One or more tracking issues related to the change. You can use the PR number here if no issue exists.
13+
issues: [40878]
14+
15+
# (Optional) One or more lines of additional information to render under the primary note.
16+
# These lines will be padded with 2 spaces and then inserted directly into the document.
17+
# Use pipe (|) for multiline entries.
18+
subtext:
19+
20+
# If your change doesn't affect end users or the exported elements of any package,
21+
# you should instead start your pull request title with [chore] or use the "Skip Changelog" label.
22+
# Optional: The change log or logs in which this entry should be included.
23+
# e.g. '[user]' or '[user, api]'
24+
# Include 'user' if the change is relevant to end users.
25+
# Include 'api' if there is a change to a library API.
26+
# Default: '[user]'
27+
change_logs: [user]

receiver/githubreceiver/README.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
- [Receiver Configuration](#receiver-configuration)
2525
- [Configuring Service Name](#configuring-service-name)
2626
- [Configuration a GitHub App](#configuration-a-github-app)
27+
- [Custom Properties as Resource Attributes](#custom-properties-as-resource-attributes)
2728

2829
## Overview
2930

@@ -220,3 +221,51 @@ create a GitHub App. During the subscription phase, subscribe to `workflow_run`
220221
[run]: https://github.com/krzko/run-with-telemetry
221222
[otcli]: https://github.com/equinix-labs/otel-cli
222223
[tr]: ./trace_event_handling.go
224+
225+
### Custom Properties as Resource Attributes
226+
227+
The GitHub receiver supports adding custom properties from GitHub repositories as resource attributes in your telemetry data. This allows users to enrich traces and events with additional metadata specific to each repository.
228+
229+
#### How It Works
230+
231+
When a GitHub webhook event is received, the receiver extracts all custom properties from the repository and adds them as resource attributes with the prefix `github.repository.custom_properties`.
232+
233+
For example, if your repository has these custom properties:
234+
235+
```
236+
classification: public
237+
service-tier: critical
238+
slack-support-channel: #observability-alerts
239+
team-name: observability-engineering
240+
```
241+
242+
They will be added as resource attributes:
243+
244+
```
245+
github.repository.custom_properties.classification: "public"
246+
github.repository.custom_properties.service_tier: "critical"
247+
github.repository.custom_properties.slack_support_channel: "#observability-alerts"
248+
github.repository.custom_properties.team_name: "observability-engineering"
249+
```
250+
251+
#### Key Formatting
252+
253+
To ensure consistency with OpenTelemetry naming conventions, all custom property keys are converted to snake_case format using the following rules:
254+
255+
1. Hyphens, spaces, and dots are replaced with underscores
256+
2. Special characters like `$` and `#` are replaced with `_dollar_` and `_hash_`
257+
3. CamelCase and PascalCase are converted to snake_case by inserting underscores before uppercase letters
258+
4. Multiple consecutive underscores are replaced with a single underscore
259+
260+
Examples of key transformations:
261+
262+
| Original Key | Transformed Key |
263+
|--------------|----------------|
264+
| `teamName` | `team_name` |
265+
| `API-Key` | `api_key` |
266+
| `Service.Level` | `service_level` |
267+
| `$Cost` | `_dollar_cost` |
268+
| `#Priority` | `_hash_priority` |
269+
270+
**Note**:
271+
The `service_name` custom property is handled specially and is not added as a resource attribute with the prefix. Instead, it's used to set the `service.name` resource attribute directly, as described in the [Configuring Service Name](#configuring-service-name) section.

receiver/githubreceiver/model.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ package githubreceiver // import "github.com/open-telemetry/opentelemetry-collec
55

66
import (
77
"errors"
8+
"fmt"
89
"strings"
10+
"unicode"
911

1012
"github.com/google/go-github/v72/github"
1113
"go.opentelemetry.io/collector/pdata/pcommon"
@@ -105,6 +107,7 @@ const (
105107
// The following attributes are exclusive to GitHub but not listed under
106108
// Vendor Extensions within Semantic Conventions yet.
107109
AttributeGitHubAppInstallationID = "github.app.installation.id" // GitHub's Installation ID
110+
AttributeGitHubRepositoryCustomProperty = "github.repository.custom_properties" // GitHub's Repository Custom Properties
108111
AttributeGitHubWorkflowRunAttempt = "github.workflow.run.attempt" // GitHub's Run Attempt
109112
AttributeGitHubWorkflowTriggerActorUsername = "github.workflow.trigger.actor.username" // GitHub's Triggering Actor Username
110113

@@ -147,6 +150,9 @@ func (gtr *githubTracesReceiver) getWorkflowRunAttrs(resource pcommon.Resource,
147150

148151
attrs.PutStr(string(semconv.ServiceNameKey), svc)
149152

153+
// Add all custom properties from the repository as resource attributes
154+
addCustomPropertiesToAttrs(attrs, e.GetRepo().CustomProperties)
155+
150156
// VCS Attributes
151157
attrs.PutStr(AttributeVCSRepositoryName, e.GetRepo().GetName())
152158
attrs.PutStr(AttributeVCSVendorName, "github")
@@ -220,6 +226,9 @@ func (gtr *githubTracesReceiver) getWorkflowJobAttrs(resource pcommon.Resource,
220226

221227
attrs.PutStr(string(semconv.ServiceNameKey), svc)
222228

229+
// Add all custom properties from the repository as resource attributes
230+
addCustomPropertiesToAttrs(attrs, e.GetRepo().CustomProperties)
231+
223232
// VCS Attributes
224233
attrs.PutStr(AttributeVCSRepositoryName, e.GetRepo().GetName())
225234
attrs.PutStr(AttributeVCSVendorName, "github")
@@ -317,6 +326,45 @@ func (gtr *githubTracesReceiver) getServiceName(customProps any, repoName string
317326
}
318327
}
319328

329+
// addCustomPropertiesToAttrs adds all custom properties from the repository as resource attributes
330+
// with the prefix AttributeGitHubCustomProperty. Keys are converted to snake_case to follow
331+
// resource attribute naming convention.
332+
func addCustomPropertiesToAttrs(attrs pcommon.Map, customProps map[string]any) {
333+
if len(customProps) == 0 {
334+
return
335+
}
336+
337+
for key, value := range customProps {
338+
// Skip service_name as it's already handled separately
339+
if key == "service_name" {
340+
continue
341+
}
342+
343+
// Convert key to snake_case
344+
snakeCaseKey := toSnakeCase(key)
345+
346+
// Use dot notation for keys, following resource attribute naming convention
347+
attrKey := fmt.Sprintf("%s.%s", AttributeGitHubRepositoryCustomProperty, snakeCaseKey)
348+
349+
// Handle different value types
350+
switch v := value.(type) {
351+
case string:
352+
attrs.PutStr(attrKey, v)
353+
case int:
354+
attrs.PutInt(attrKey, int64(v))
355+
case int64:
356+
attrs.PutInt(attrKey, v)
357+
case float64:
358+
attrs.PutDouble(attrKey, v)
359+
case bool:
360+
attrs.PutBool(attrKey, v)
361+
default:
362+
// For any other types, convert to string
363+
attrs.PutStr(attrKey, fmt.Sprintf("%v", v))
364+
}
365+
}
366+
}
367+
320368
// formatString formats a string to lowercase and replaces underscores with
321369
// hyphens.
322370
func formatString(input string) string {
@@ -328,3 +376,41 @@ func replaceAPIURL(apiURL string) (htmlURL string) {
328376
// TODO: Support enterpise server configuration with custom domain.
329377
return strings.Replace(apiURL, "api.github.com/repos", "github.com", 1)
330378
}
379+
380+
// toSnakeCase converts a string to snake_case format.
381+
// It handles all GitHub supported characters for custom property names: a-z, A-Z, 0-9, _, -, $, #.
382+
// This function ensures that the resulting string follows snake_case convention.
383+
func toSnakeCase(s string) string {
384+
// Replace hyphens, spaces, and dots with underscores
385+
s = strings.ReplaceAll(s, "-", "_")
386+
s = strings.ReplaceAll(s, " ", "_")
387+
s = strings.ReplaceAll(s, ".", "_")
388+
389+
// Replace special characters with underscores
390+
s = strings.ReplaceAll(s, "$", "_dollar_")
391+
s = strings.ReplaceAll(s, "#", "_hash_")
392+
393+
// Handle camelCase and PascalCase
394+
var result strings.Builder
395+
for i, r := range s {
396+
if i > 0 && unicode.IsUpper(r) {
397+
// If current char is uppercase and previous char is lowercase or a digit,
398+
// or if current char is uppercase and next char is lowercase,
399+
// add an underscore before the current char
400+
prevIsLower := i > 0 && (unicode.IsLower(rune(s[i-1])) || unicode.IsDigit(rune(s[i-1])))
401+
nextIsLower := i < len(s)-1 && unicode.IsLower(rune(s[i+1]))
402+
if prevIsLower || nextIsLower {
403+
result.WriteRune('_')
404+
}
405+
}
406+
result.WriteRune(unicode.ToLower(r))
407+
}
408+
409+
// Replace multiple consecutive underscores with a single one
410+
output := result.String()
411+
for strings.Contains(output, "__") {
412+
output = strings.ReplaceAll(output, "__", "_")
413+
}
414+
415+
return output
416+
}

receiver/githubreceiver/model_test.go

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"testing"
88

99
"github.com/stretchr/testify/assert"
10+
"go.opentelemetry.io/collector/pdata/pcommon"
1011
)
1112

1213
func TestFormatString(t *testing.T) {
@@ -123,3 +124,145 @@ func TestReplaceAPIURL(t *testing.T) {
123124
})
124125
}
125126
}
127+
128+
func TestAddCustomPropertiesToAttrs(t *testing.T) {
129+
tests := []struct {
130+
name string
131+
customProps map[string]any
132+
expectedKeys []string
133+
expectedVals map[string]any
134+
}{
135+
{
136+
name: "adds string custom properties",
137+
customProps: map[string]any{
138+
"team_name": "open-telemetry",
139+
"environment": "development",
140+
"service_name": "should-be-skipped", // This should be skipped
141+
},
142+
expectedKeys: []string{
143+
"github.repository.custom_properties.team_name",
144+
"github.repository.custom_properties.environment",
145+
},
146+
expectedVals: map[string]any{
147+
"github.repository.custom_properties.team_name": "open-telemetry",
148+
"github.repository.custom_properties.environment": "development",
149+
},
150+
},
151+
{
152+
name: "adds different types of custom properties",
153+
customProps: map[string]any{
154+
"string_prop": "string-value",
155+
"int_prop": 42,
156+
"float_prop": 3.14,
157+
"bool_prop": true,
158+
},
159+
expectedKeys: []string{
160+
"github.repository.custom_properties.string_prop",
161+
"github.repository.custom_properties.int_prop",
162+
"github.repository.custom_properties.float_prop",
163+
"github.repository.custom_properties.bool_prop",
164+
},
165+
expectedVals: map[string]any{
166+
"github.repository.custom_properties.string_prop": "string-value",
167+
"github.repository.custom_properties.int_prop": int64(42),
168+
"github.repository.custom_properties.float_prop": 3.14,
169+
"github.repository.custom_properties.bool_prop": true,
170+
},
171+
},
172+
{
173+
name: "converts keys to snake_case",
174+
customProps: map[string]any{
175+
"camelCase": "camel-value",
176+
"PascalCase": "pascal-value",
177+
"kebab-case": "kebab-value",
178+
"space case": "space-value",
179+
"mixed_Case": "mixed-value",
180+
"withNumber1": "number-value",
181+
"with.dots": "dots-value",
182+
"$dollar": "dollar-value",
183+
"#hash": "hash-value",
184+
},
185+
expectedKeys: []string{
186+
"github.repository.custom_properties.camel_case",
187+
"github.repository.custom_properties.pascal_case",
188+
"github.repository.custom_properties.kebab_case",
189+
"github.repository.custom_properties.space_case",
190+
"github.repository.custom_properties.mixed_case",
191+
"github.repository.custom_properties.with_number1",
192+
"github.repository.custom_properties.with_dots",
193+
"github.repository.custom_properties._dollar_dollar",
194+
"github.repository.custom_properties._hash_hash",
195+
},
196+
expectedVals: map[string]any{
197+
"github.repository.custom_properties.camel_case": "camel-value",
198+
"github.repository.custom_properties.pascal_case": "pascal-value",
199+
"github.repository.custom_properties.kebab_case": "kebab-value",
200+
"github.repository.custom_properties.space_case": "space-value",
201+
"github.repository.custom_properties.mixed_case": "mixed-value",
202+
"github.repository.custom_properties.with_number1": "number-value",
203+
"github.repository.custom_properties.with_dots": "dots-value",
204+
"github.repository.custom_properties._dollar_dollar": "dollar-value",
205+
"github.repository.custom_properties._hash_hash": "hash-value",
206+
},
207+
},
208+
{
209+
name: "handles empty custom properties",
210+
customProps: map[string]any{},
211+
expectedKeys: []string{},
212+
expectedVals: map[string]any{},
213+
},
214+
{
215+
name: "handles nil custom properties",
216+
customProps: nil,
217+
expectedKeys: []string{},
218+
expectedVals: map[string]any{},
219+
},
220+
}
221+
222+
for _, tt := range tests {
223+
t.Run(tt.name, func(t *testing.T) {
224+
// Create a new resource with empty attributes
225+
resource := pcommon.NewResource()
226+
attrs := resource.Attributes()
227+
228+
// Add custom properties to attributes
229+
addCustomPropertiesToAttrs(attrs, tt.customProps)
230+
231+
// Check that all expected keys exist
232+
for _, key := range tt.expectedKeys {
233+
_, exists := attrs.Get(key)
234+
assert.True(t, exists, "Expected key %s not found", key)
235+
}
236+
237+
// Check that service_name is not added
238+
_, exists := attrs.Get("github.repository.custom_properties.service_name")
239+
assert.False(t, exists)
240+
241+
// Check values
242+
for key, expectedVal := range tt.expectedVals {
243+
switch expected := expectedVal.(type) {
244+
case string:
245+
val, _ := attrs.Get(key)
246+
assert.Equal(t, expected, val.Str())
247+
case int64:
248+
val, _ := attrs.Get(key)
249+
assert.Equal(t, expected, val.Int())
250+
case float64:
251+
val, _ := attrs.Get(key)
252+
assert.Equal(t, expected, val.Double())
253+
case bool:
254+
val, _ := attrs.Get(key)
255+
assert.Equal(t, expected, val.Bool())
256+
}
257+
}
258+
259+
// Check that no unexpected keys were added
260+
count := 0
261+
attrs.Range(func(_ string, _ pcommon.Value) bool {
262+
count++
263+
return true
264+
})
265+
assert.Equal(t, len(tt.expectedKeys), count)
266+
})
267+
}
268+
}

receiver/githubreceiver/testdata/workflow-job-expected.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ resourceSpans:
44
- key: service.name
55
value:
66
stringValue: otel-collector
7+
- key: github.repository.custom_properties.team_name
8+
value:
9+
stringValue: open-telemetry
710
- key: vcs.repository.name
811
value:
912
stringValue: open-telemetry-otel-collector

receiver/githubreceiver/testdata/workflow-run-expected.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ resourceSpans:
44
- key: service.name
55
value:
66
stringValue: otel-collector
7+
- key: github.repository.custom_properties.team_name
8+
value:
9+
stringValue: open-telemetry
710
- key: vcs.repository.name
811
value:
912
stringValue: open-telemetry-otel-collector

0 commit comments

Comments
 (0)