diff --git a/.changeset/release-id-env.md b/.changeset/release-id-env.md new file mode 100644 index 0000000..33aa925 --- /dev/null +++ b/.changeset/release-id-env.md @@ -0,0 +1,5 @@ +--- +"posthog-go": minor +--- + +Report a `$release_id` on `$exception` events when `POSTHOG_RELEASE_ID` is set in the environment. This is the native, deploy-time counterpart to injecting `$release_id` into a web bundle: a build tool creates the release with `posthog-cli release resolve`, launches the app with the printed id in `POSTHOG_RELEASE_ID`, and the SDK stamps it on each exception so the server resolves that exception's release by a direct id lookup — no release name or version has to match anything the app reports. Only exception events carry it (that is where a release is resolved), the variable is read once, and an unset or blank value changes nothing. diff --git a/capture_v1.go b/capture_v1.go index 3e06f86..fd5ee22 100644 --- a/capture_v1.go +++ b/capture_v1.go @@ -359,6 +359,10 @@ func (msg Exception) apifyEvent() apiEvent { if len(msg.DebugImages) > 0 { myProperties.Set("$debug_images", msg.DebugImages) } + // The release id from POSTHOG_RELEASE_ID, if set (see error_tracking.go APIfy for the v0 path). + if releaseID := releaseIDFromEnv(); releaseID != nil { + myProperties.Set("$release_id", *releaseID) + } return apiEvent{ event: "$exception", diff --git a/error_tracking.go b/error_tracking.go index c405456..7d782db 100644 --- a/error_tracking.go +++ b/error_tracking.go @@ -146,6 +146,9 @@ type ExceptionInApiProperties struct { ExceptionList []ExceptionItem `json:"$exception_list"` // ExceptionFingerprint is sent as $exception_fingerprint when provided. ExceptionFingerprint *string `json:"$exception_fingerprint,omitempty"` + // ReleaseId is sent as $release_id when POSTHOG_RELEASE_ID is set, so the server resolves the + // exception's release by a direct id lookup. Only exception events carry it. + ReleaseId *string `json:"$release_id,omitempty"` // Custom is flattened into the wire "properties" on marshal. // Typed fields win on collision. @@ -258,6 +261,7 @@ func (msg Exception) APIfy() APIMessage { DebugImages: msg.DebugImages, ExceptionList: msg.ExceptionList, ExceptionFingerprint: msg.ExceptionFingerprint, + ReleaseId: releaseIDFromEnv(), Custom: msg.Properties, }, } diff --git a/release_env.go b/release_env.go new file mode 100644 index 0000000..fb07027 --- /dev/null +++ b/release_env.go @@ -0,0 +1,40 @@ +package posthog + +import ( + "os" + "strings" + "sync" +) + +// releaseIDEnvVar is the environment variable the SDK reads the release id from. +// +// This is the native, deploy-time counterpart to injecting $release_id into a web bundle. A +// compiled binary has no bundle, so a build tool creates the release with `posthog-cli release +// resolve`, launches the app with the printed id in this variable, and the SDK reports it as +// $release_id on every exception — so the server resolves the exception's release by a direct id +// lookup, with no release name or version having to match anything the app reports. +const releaseIDEnvVar = "POSTHOG_RELEASE_ID" + +var ( + releaseIDOnce sync.Once + releaseIDValue *string +) + +// releaseIDFromEnv returns the release id from POSTHOG_RELEASE_ID, read once. It returns nil when +// the variable is unset or blank, so no $release_id is sent. +func releaseIDFromEnv() *string { + releaseIDOnce.Do(func() { + releaseIDValue = normalizeReleaseID(os.Getenv(releaseIDEnvVar)) + }) + return releaseIDValue +} + +// normalizeReleaseID trims the raw value and treats a blank string as unset, so POSTHOG_RELEASE_ID= +// (or whitespace) does not send an empty $release_id. +func normalizeReleaseID(raw string) *string { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return nil + } + return &trimmed +} diff --git a/release_env_test.go b/release_env_test.go new file mode 100644 index 0000000..7341b48 --- /dev/null +++ b/release_env_test.go @@ -0,0 +1,71 @@ +package posthog + +import "testing" + +func TestNormalizeReleaseID(t *testing.T) { + if got := normalizeReleaseID(""); got != nil { + t.Errorf("unset: want nil, got %q", *got) + } + if got := normalizeReleaseID(" "); got != nil { + t.Errorf("blank: want nil, got %q", *got) + } + const id = "01a04245-8c54-0000-7530-28eed93002b0" + if got := normalizeReleaseID(" " + id + " "); got == nil || *got != id { + t.Errorf("value: want %q trimmed, got %v", id, got) + } +} + +// forceReleaseID pins the cached POSTHOG_RELEASE_ID value for a test and restores it afterwards, +// bypassing the process-global env read (which is behind a sync.Once). +func forceReleaseID(t *testing.T, value *string) { + t.Helper() + releaseIDOnce.Do(func() {}) // spend the Once so releaseIDFromEnv returns releaseIDValue + old := releaseIDValue + releaseIDValue = value + t.Cleanup(func() { releaseIDValue = old }) +} + +func TestReleaseIDIsAddedOnlyToExceptionEvents(t *testing.T) { + const id = "01a04245-8c54-0000-7530-28eed93002b0" + forceReleaseID(t, Ptr(id)) + + exc := Exception{ + DistinctId: "user-1", + ExceptionList: []ExceptionItem{{Type: "Error", Value: "boom"}}, + } + + // v0 / callback path. + api, ok := exc.APIfy().(ExceptionInApi) + if !ok { + t.Fatalf("APIfy did not return ExceptionInApi") + } + if api.Properties.ReleaseId == nil || *api.Properties.ReleaseId != id { + t.Errorf("APIfy: want $release_id %q, got %v", id, api.Properties.ReleaseId) + } + + // v1 path. + if got, _ := exc.apifyEvent().properties["$release_id"].(string); got != id { + t.Errorf("apifyEvent: want $release_id %q, got %q", id, got) + } + + // A non-exception event must not carry it — the release is only resolved on exceptions. + capEv := Capture{Event: "custom_event", DistinctId: "user-1"}.apifyEvent() + if _, present := capEv.properties["$release_id"]; present { + t.Errorf("capture event must not carry $release_id") + } +} + +func TestReleaseIDAbsentWhenUnset(t *testing.T) { + forceReleaseID(t, nil) + + exc := Exception{ + DistinctId: "user-1", + ExceptionList: []ExceptionItem{{Type: "Error", Value: "boom"}}, + } + if api := exc.APIfy().(ExceptionInApi); api.Properties.ReleaseId != nil { + t.Errorf("unset: want nil $release_id, got %q", *api.Properties.ReleaseId) + } + if _, present := exc.apifyEvent().properties["$release_id"]; present { + t.Errorf("unset: exception must not carry $release_id") + } +}