Skip to content

feat: report $release_id from POSTHOG_RELEASE_ID on exceptions - #301

Closed
ablaszkiewicz wants to merge 1 commit into
mainfrom
ab/feat/release-id-env
Closed

feat: report $release_id from POSTHOG_RELEASE_ID on exceptions#301
ablaszkiewicz wants to merge 1 commit into
mainfrom
ab/feat/release-id-env

Conversation

@ablaszkiewicz

@ablaszkiewicz ablaszkiewicz commented Aug 27, 2026

Copy link
Copy Markdown

Problem

A compiled Go binary carries no release. A JavaScript build injects $release_id into its bundle, but a Go binary has neither.

Change

Report a $release_id on $exception events when POSTHOG_RELEASE_ID is set in the environment — the native, deploy-time counterpart to the web $release_id:

  1. A build tool runs posthog-cli release resolve to create the release and print its id.
  2. It launches the app with that id in POSTHOG_RELEASE_ID.
  3. The SDK reads the variable at runtime and stamps $release_id on each exception.

The server then resolves each exception's release by a direct id lookup, so no release name or version has to match anything the app reports.

Only $exception events carry the property — that is the only event the server resolves a release from, so a normal Capture does not need it. The id is read once (cached) and added on both the v0 (APIfy) and v1 (apifyEvent) exception paths, so it is consistent across capture modes. An unset or blank value changes nothing. No binary patching and no code signing.

Contract: the variable is POSTHOG_RELEASE_ID, holding the release id (a UUID) that posthog-cli release resolve prints. Upload the Go binary's symbols release-independent with symbol-sets upload --release-mode=event (PostHog/posthog#89834), so one symbol set serves every release.

This mirrors the Rust SDK change (PostHog/posthog-rs#239): the env-var flow is SDK-agnostic — any native SDK that reads POSTHOG_RELEASE_ID participates.

How did you test this code?

go test ./... — new unit tests for the value normalization (unset → none, blank → none, surrounding whitespace trimmed) and for the exception scoping: a $release_id lands on an $exception (verified on both the v0 APIfy and the v1 apifyEvent wire paths), not on a non-exception Capture, and not at all when unset. The full suite stays green; go vet and gofmt clean.

new tests
--- PASS: TestNormalizeReleaseID
--- PASS: TestReleaseIDIsAddedOnlyToExceptionEvents
--- PASS: TestReleaseIDAbsentWhenUnset
ok  github.com/posthog/posthog-go

The positive env-read path is exercised through the wire-format structs (ExceptionInApi / the v1 event) rather than by faking the env: the variable is read behind a sync.Once, so a test pins the cached value directly.

End to end against a local PostHog stack, run by the agent (Claude). A minimal go-release-env example (a plain posthog-go app, alpha → beta → gamma → capture) is built against this branch, its symbols upload release-independent, its exception resolves its release purely from the reported $release_id, and its frames show Go source context.

posthog-cli — release-independent symbol-sets upload of the Go binary, then release resolve
# Go embeds DWARF, so the CLI reads it straight from the Mach-O (built with -ldflags=-compressdwarf=false).
# --include-source bundles the .go files so frames show source context.
$ posthog-cli symbol-sets upload --directory . --release-mode=event --include-source
INFO  Found 1 native debug file(s) and 0 dSYM bundle(s)
INFO  Processing .../go-release-env (debug id 75F086B0-0548-3B10-E94D-E63C7F5129AB)
INFO  Collected 40 source files
INFO  --release-mode=event: uploading symbol sets release-independent; the release is carried on each event as $release_id (POSTHOG_RELEASE_ID)
INFO  Upload summary: 1 chunk(s) uploaded, 0 skipped

# the release is named here, and its id is what the app reports
$ posthog-cli release resolve --release-name go-release-env --release-version 2.0.0
01a04367-c799-0000-dbe9-a7b5d6121d6b
the app, launched with the resolved id in POSTHOG_RELEASE_ID
$ export POSTHOG_RELEASE_ID=$(posthog-cli release resolve --release-name go-release-env --release-version 2.0.0)
$ ./go-release-env
starting — release 01a04367-c799-0000-dbe9-a7b5d6121d6b (from POSTHOG_RELEASE_ID)
stopping

Read back server-side (a dev-login session over the local API): the $exception ($lib = posthog-go) carries the reported $release_id, cymbal resolved it into a $exception_release, and the app frames symbolicate to source off the uploaded symbol set:

$lib               : posthog-go
$release_id        : 01a04367-c799-0000-dbe9-a7b5d6121d6b
frames (in app)    : main.gamma three.go:11  main.beta two.go:6  main.alpha one.go:6  main.main main.go:30
$exception_release : { project: "go-release-env", version: "2.0.0", id: 01a04367-c799-… }
Symbolicated stack trace, with Go source context Release resolved via the reported $release_id
go-stack go-release

The binary carries no release-specific code — only POSTHOG_RELEASE_ID in the environment names the release. This matches the Rust pair (PostHog/posthog-rs#239); the two SDKs produce the identical $exception shape.

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Authored by Claude in Claude Code, directed by @ablaszkiewicz (DRI). This brings the env-variable $release_id approach (already done for Rust in PostHog/posthog-rs#239, with the CLI side in PostHog/posthog#89834) to the Go SDK. The repo was cloned locally for this change. The end-to-end verification above was run by the agent against a local PostHog dev stack (ingestion + cymbal), with screenshots uploaded via hogli pr:upload-image; the example data is invented (go-release-env) and draws on no customer material.

🤖 Generated with Claude Code

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.

The id is read once (cached), added on both the v0 (`APIfy`) and v1
(`apifyEvent`) exception paths, and only on `$exception` events (that is
where a release is resolved). An unset or blank value changes nothing. No
binary patching and no code signing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@ablaszkiewicz ablaszkiewicz self-assigned this Aug 27, 2026
@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

posthog-go Compliance Report

Date: 2026-08-27 12:22:20 UTC
Duration: 169230ms

✅ All Tests Passed!

111/111 tests passed


Capture_V1 Tests

94/94 tests passed

View Details
Test Status Duration
Endpoint And Method.Targets V1 Endpoint 25ms
Endpoint And Method.Does Not Use Legacy Endpoints 6ms
Required Headers.Has Authorization Bearer Header 6ms
Required Headers.Has Content Type Json 5ms
Required Headers.Has Posthog Sdk Info Format 6ms
Required Headers.Has Posthog Attempt Header 6ms
Required Headers.Has Posthog Request Id 6ms
Required Headers.Has Posthog Request Timestamp 6ms
Required Headers.Has User Agent 6ms
Body Format.Body Has Created At And Batch 6ms
Body Format.No Api Key In Body 6ms
Body Format.No Sent At In Body 7ms
Event Format.Event Has Required Root Fields 5ms
Event Format.Event Uuid Is Valid 6ms
Event Format.Event Timestamp Is Rfc3339 7ms
Event Format.Distinct Id Is String 5ms
Event Format.Distinct Id At Root Not Properties 6ms
Event Format.Custom Properties Preserved 6ms
Event Format.Set Properties Preserved 7ms
Event Format.Set Once Properties Preserved 5ms
Event Format.Groups Properties Preserved 7ms
Event Format.Sdk Generates Uuid If Not Provided 5ms
Event Format.Event Has Required Root Fields Batch 9ms
Event Format.Event Uuid Is Valid Batch 9ms
Event Format.Event Timestamp Is Rfc3339 Batch 8ms
Event Format.Distinct Id Is String Batch 8ms
Event Format.Distinct Id At Root Not Properties Batch 8ms
Event Format.Custom Properties Preserved Batch 8ms
Event Format.Set Properties Preserved Batch 8ms
Event Format.Set Once Properties Preserved Batch 9ms
Event Format.Groups Properties Preserved Batch 8ms
Event Format.Sdk Generates Uuid If Not Provided Batch 8ms
Batch Behavior.Multiple Events In Single Batch 32ms
Batch Behavior.Batch Envelope Smoke 10ms
Batch Behavior.Flush With No Events Sends Nothing 4ms
Batch Behavior.Flush At Triggers Batch 1008ms
Batch Behavior.Created At Reflects Batch Creation Time 7ms
Deduplication.Generates Unique Uuids 24ms
Deduplication.Different Events Same Content Different Uuids 8ms
Deduplication.Preserves Uuid On Retry 5135ms
Deduplication.Preserves Timestamp On Retry 5135ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry 5137ms
Deduplication.No Duplicate Events In Batch 32ms
Header Behavior On Retry.Attempt Header Starts At One 6ms
Header Behavior On Retry.Attempt Header Increments On Retry 10135ms
Header Behavior On Retry.Request Id Preserved On Retry 5136ms
Header Behavior On Retry.Different Requests Have Different Request Ids 2024ms
Header Behavior On Retry.Request Timestamp Changes On Retry 5135ms
Response Format Validation.Success Response Has Uuid Keyed Results 8ms
Response Format Validation.Success Response Has Ok For Each Event 27ms
Response Format Validation.Success No Retry After When All Ok 28ms
Response Format Validation.Success Retry After Present When Retry Events 130ms
Response Format Validation.Success No Retry After When Drop Only 27ms
Response Format Validation.Response Echoes Request Id 7ms
Retry Behavior.Retries On 408 5134ms
Retry Behavior.Retries On 500 5136ms
Retry Behavior.Retries On 503 5134ms
Retry Behavior.Retries On 504 5136ms
Retry Behavior.Retryable Errors Have Retry After 2133ms
Retry Behavior.Respects Retry After On Retryable Error 8140ms
Retry Behavior.Does Not Retry On 400 2133ms
Retry Behavior.Does Not Retry On 401 2133ms
Retry Behavior.Does Not Retry On 402 2133ms
Retry Behavior.Does Not Retry On 413 2134ms
Retry Behavior.Does Not Retry On 415 2133ms
Retry Behavior.Non Retryable Errors Have No Retry After 2133ms
Retry Behavior.Implements Backoff 15145ms
Retry Behavior.Max Retries Respected 15134ms
Partial Batch Handling.Handles 200 Full Success 2009ms
Partial Batch Handling.Handles 200 With All Ok 3031ms
Partial Batch Handling.Does Not Retry Dropped Events 3031ms
Partial Batch Handling.Does Not Retry Limited Events 3031ms
Partial Batch Handling.Prunes Ok Events On Partial Retry 5136ms
Partial Batch Handling.Prunes Dropped Events On Partial Retry 5137ms
Partial Batch Handling.Retries Only Retry Events From Partial 5137ms
Partial Batch Handling.Partial Retry Preserves Uuids 5136ms
Partial Batch Handling.Partial Retry Attempt Header Increments 5135ms
Partial Batch Handling.Partial Retry Request Id Preserved 5133ms
Partial Batch Handling.Respects Retry After On Partial 5135ms
Partial Batch Handling.Unknown Result Treated As Terminal 3031ms
Partial Batch Handling.Mixed Ok Drop Limited No Retry 3034ms
Compression.Sends Gzip Content Encoding 16ms
Compression.No Content Encoding When Disabled 16ms
Compression.Compressed Body Is Decompressible 7ms
Error Handling.Does Not Retry On Unknown 4Xx 2131ms
Event Options.Cookieless Mode Override 8ms
Event Options.Disable Skew Correction Override 6ms
Event Options.Process Person Profile Override 6ms
Event Options.Product Tour Id Override 7ms
Event Options.Unset Options Omitted 5ms
Event Options.Options Override In Batch 9ms
Geoip And Historical Migration.Geoip Disable Injected Into Properties 6ms
Geoip And Historical Migration.Historical Migration Set In Body 6ms
Geoip And Historical Migration.Historical Migration Absent By Default 16ms

Feature_Flags Tests

17/17 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id 16ms
Request Payload.Flags Request Uses V2 Query Param 16ms
Request Payload.Flags Request Hits Flags Path Not Decide 16ms
Request Payload.Flags Request Omits Authorization Header 16ms
Request Payload.Token In Flags Body Matches Init 16ms
Request Payload.Groups Round Trip 16ms
Request Payload.Groups Default To Empty Object 16ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False 16ms
Request Payload.Disable Geoip Omitted Defaults To False 15ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key 16ms
Request Lifecycle.No Flags Request On Init Alone 3ms
Request Lifecycle.No Flags Request On Normal Capture 6ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests 30ms
Request Lifecycle.Mock Response Value Is Returned To Caller 16ms
Retry Behavior.Retries Flags On 502 17ms
Retry Behavior.Retries Flags On 504 18ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event 17ms

Comment thread release_env.go
Comment on lines +16 to +30
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
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

similar to my comment on the rust PR, should this come from ldflags instead? otherwise this is a runtime requirement instead of a build time requirement

@ablaszkiewicz

Copy link
Copy Markdown
Author

We wont be migrating go/rust to the new release system

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants