Repository: getsentry/sentry-go

Issue:
SENTRY_TAGS_* environment variables
## Summary

The sentry-logback implementation supports the injection of tags through the `SENTRY_TAGS_<key>=<value>` environment variables. It would be great if the Go SDK would support this too 🙂 

## Motivation

We would like to inject some additional context as tags into each event, especially things like the Kubernetes pod and Kubernetes namespace that event got triggered in. In Java services we have for that set `SENTRY_TAGS_K8S_NAMESPACE` and `SENTRY_TAGS_K8S_POD` and would like to do that also for our Go applications.

## Sample

I've added a test-case + sample implementation here: https://github.com/zerok/sentry-go/commit/36dc6dbb540ea558e0d1225f2f4493b584f0244c

Right now we can work around this using a custom eventprocessor but it would be nice if the Java and Go SDKs would behave in a similar way here out of the box 🙂 


Hints from issue comments:
@dennwc: Is there anything missing in the sample implementation which prevents merging it upstream?@rhcarvalho: @dennwc would you mind sharing what is your use case?

A quick look onto https://github.com/zerok/sentry-go/commit/36dc6dbb540ea558e0d1225f2f4493b584f0244c and I see some things would need to be better defined, for example, as implemented, passing `ClientOptions.Tags` when initializing the SDK would ignore the environment.

Also comes to mind that some SDKs have a concept of `InitialScope`, in that case tags could naturally be there.

This is really so simple to add with `EventProcessor`, and with total flexibility on behavior (e.g. add to all events, add to errors only, add to transactions only, etc) that I'm not seeing the value of maintaining a limited version which makes the API surface larger (we could read tags from env without adding new fields to ClientOptions).@dennwc: It's actually very similar to what is described above: mostly passing K8S or other deployment-related variables.

So I agree that it might be okay to keep `ClientOptions` as-is. At least for our case we will pass everything via environment.@cleptric: Closing, as the route via an `EventProcessor` seems to be feasible.@seanhoughton: Will someone clarify why automatically supporting this in sentry.Init() is not the best solution? This is seems like a fairly obvious and useful feature that other language implementations do as well as other libraries (e.g. OpenTelemetry). I understand the extra features you get from using a custom `EventProcessor` but at the cost of more complexity and some runtime cost. I would expect a significant majority of users do not want or need that extra complexity and would like something out of the box that just works without any code changes.

I guess until this is resolved we duplicate this "user space" code in every application with Sentry integration.

```
func loadTags() map[string]string {
	tags := map[string]string{}
	for _, pair := range os.Environ() {
		parts := strings.Split(pair, "=")
		if !strings.HasPrefix(parts[0], "SENTRY_TAGS_") {
			continue
		}
		tag := strings.TrimPrefix(parts[0], "SENTRY_TAGS_")
		tag = strings.ToLower(tag)
		tags[tag] = parts[1]
	}
	return tags
}

func main() {
	defer sentry.Recover()
	sentry.WithScope(func(scope *sentry.Scope) {
		scope.SetTags(loadTags())
		cmd.Execute()
	})
}
```@cleptric: We added the `tags` option back to PHP a while ago (https://github.com/getsentry/sentry-php/pull/1561), and we can also add it to the Go SDK with support for setting tags via env.

@greywolve can you please pick this up? 😃@cleptric: This was released in [0.25.0](https://github.com/getsentry/sentry-go/releases/tag/v0.25.0).

Solution patch:
diff --git a/client.go b/client.go
index 6f7b62f..0e14658 100644
--- a/client.go
+++ b/client.go
@@ -224,6 +224,8 @@ type ClientOptions struct {
 	// is not optimized for long chains either. The top-level error together with a
 	// stack trace is often the most useful information.
 	MaxErrorDepth int
+	// Default event tags. These are overridden by tags set on a scope.
+	Tags map[string]string
 }
 
 // Client is the underlying processor that is used by the main API and Hub
@@ -375,6 +377,7 @@ func (client *Client) setupIntegrations() {
 		new(modulesIntegration),
 		new(ignoreErrorsIntegration),
 		new(ignoreTransactionsIntegration),
+		new(globalTagsIntegration),
 	}
 
 	if client.options.Integrations != nil {
diff --git a/integrations.go b/integrations.go
index f5ef3ef..046ef0a 100644
--- a/integrations.go
+++ b/integrations.go
@@ -2,6 +2,7 @@ package sentry
 
 import (
 	"fmt"
+	"os"
 	"regexp"
 	"runtime"
 	"runtime/debug"
@@ -325,3 +326,66 @@ func (cfi *contextifyFramesIntegration) addContextLinesToFrame(frame Frame, line
 	}
 	return frame
 }
+
+// ================================
+// Global Tags Integration
+// ================================
+
+const envTagsPrefix = "SENTRY_TAGS_"
+
+type globalTagsIntegration struct {
+	tags    map[string]string
+	envTags map[string]string
+}
+
+func (ti *globalTagsIntegration) Name() string {
+	return "GlobalTags"
+}
+
+func (ti *globalTagsIntegration) SetupOnce(client *Client) {
+	ti.tags = make(map[string]string, len(client.options.Tags))
+	for k, v := range client.options.Tags {
+		ti.tags[k] = v
+	}
+
+	ti.envTags = loadEnvTags()
+
+	client.AddEventProcessor(ti.processor)
+}
+
+func (ti *globalTagsIntegration) processor(event *Event, hint *EventHint) *Event {
+	if len(ti.tags) == 0 && len(ti.envTags) == 0 {
+		return event
+	}
+
+	if event.Tags == nil {
+		event.Tags = make(map[string]string, len(ti.tags)+len(ti.envTags))
+	}
+
+	for k, v := range ti.tags {
+		if _, ok := event.Tags[k]; !ok {
+			event.Tags[k] = v
+		}
+	}
+
+	for k, v := range ti.envTags {
+		if _, ok := event.Tags[k]; !ok {
+			event.Tags[k] = v
+		}
+	}
+
+	return event
+}
+
+func loadEnvTags() map[string]string {
+	tags := map[string]string{}
+	for _, pair := range os.Environ() {
+		parts := strings.Split(pair, "=")
+		if !strings.HasPrefix(parts[0], envTagsPrefix) {
+			continue
+		}
+		tag := strings.TrimPrefix(parts[0], envTagsPrefix)
+		tags[tag] = parts[1]
+	}
+	return tags
+}



Test patch:
diff --git a/integrations_test.go b/integrations_test.go
index 0b97a9b..bd46388 100644
--- a/integrations_test.go
+++ b/integrations_test.go
@@ -2,6 +2,7 @@ package sentry
 
 import (
 	"encoding/json"
+	"os"
 	"path/filepath"
 	"regexp"
 	"runtime/debug"
@@ -421,3 +422,50 @@ func TestEnvironmentIntegrationDoesNotOverrideExistingContexts(t *testing.T) {
 		t.Errorf(`contexts["custom"]["key"] = %#v, want "value"`, contexts["custom"]["key"])
 	}
 }
+
+func TestGlobalTagsIntegration(t *testing.T) {
+	os.Setenv("SENTRY_TAGS_foo", "foo_value_env")
+	os.Setenv("SENTRY_TAGS_bar", "bar_value_env")
+	os.Setenv("SENTRY_TAGS_baz", "baz_value_env")
+	defer os.Unsetenv("SENTRY_TAGS_foo")
+	defer os.Unsetenv("SENTRY_TAGS_bar")
+	defer os.Unsetenv("SENTRY_TAGS_baz")
+
+	transport := &TransportMock{}
+	client, err := NewClient(ClientOptions{
+		Transport: transport,
+		Tags: map[string]string{
+			"foo": "foo_value_client_options",
+			"baz": "baz_value_client_options",
+		},
+		Integrations: func([]Integration) []Integration {
+			return []Integration{new(globalTagsIntegration)}
+		},
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	scope := NewScope()
+	scope.SetTags(map[string]string{"foo": "foo_value_scope"})
+
+	event := NewEvent()
+	event.Message = "event message"
+	client.CaptureEvent(event, nil, scope)
+
+	assertEqual(t,
+		transport.lastEvent.Tags["foo"],
+		"foo_value_scope",
+		"scope tag should override any global tag",
+	)
+	assertEqual(t,
+		transport.lastEvent.Tags["bar"],
+		"bar_value_env",
+		"env tag present if not overridden by scope or client options tags",
+	)
+	assertEqual(t,
+		transport.lastEvent.Tags["baz"],
+		"baz_value_client_options",
+		"client options tag present if not overridden by scope and overrides env tag",
+	)
+}