Skip to content

Commit b3f561f

Browse files
committed
feat(telemetry): attach email + name + signup_date to PostHog Person
Login now passes the API User struct's Email, DisplayName, Username, and CreatedAt as PostHog Person-level properties via Identify. Mutable fields (email, name, username) use $set; signup_date uses $set_once so it cannot be overwritten by subsequent logins. Person properties go ONLY to PostHog's person record via Identify — they are NEVER attached to Capture event payloads. Implementation: - internal/telemetry/client.go: new SetPersonProperties(props) method; RebindIdentity merges them into the Identify event. - cmd/auth/login.go: bindIdentityAndCapture builds props from User and calls SetPersonProperties before RebindIdentity. Verified end-to-end against PostHog: person record now shows email + signup_date alongside the existing user_id binding. Events still carry no email — only user_id.
1 parent 20fc17e commit b3f561f

2 files changed

Lines changed: 69 additions & 4 deletions

File tree

cmd/auth/login.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ func captureLoginFailure(method string, err error) {
4949
// account on this machine would mis-attribute the new login's telemetry.
5050
func bindIdentityAndCapture(apiClient *api.APIClient, method string) {
5151
identityFresh := false
52+
var personProps map[string]any
5253
if apiClient != nil {
5354
if u, err := apiClient.GetUser(); err == nil && u != nil && u.ID != "" {
5455
id := config.Identity{UserID: u.ID}
@@ -57,6 +58,7 @@ func bindIdentityAndCapture(apiClient *api.APIClient, method string) {
5758
}
5859
if saveErr := config.SaveIdentity(id); saveErr == nil {
5960
identityFresh = true
61+
personProps = userToPersonProps(u)
6062
}
6163
}
6264
// Silent on /me failure — login still succeeds without user_id.
@@ -70,6 +72,7 @@ func bindIdentityAndCapture(apiClient *api.APIClient, method string) {
7072

7173
if telemetry.Default != nil {
7274
if identityFresh {
75+
telemetry.Default.SetPersonProperties(personProps)
7376
telemetry.Default.RebindIdentity()
7477
}
7578
telemetry.Default.Capture("auth_event", map[string]any{
@@ -80,6 +83,27 @@ func bindIdentityAndCapture(apiClient *api.APIClient, method string) {
8083
}
8184
}
8285

86+
// userToPersonProps maps the API User struct to PostHog Person-level
87+
// properties. These go ONLY to the Person record via Identify; they are
88+
// NOT included in any Capture event payload. Pointer fields are dereferenced
89+
// only when non-nil.
90+
func userToPersonProps(u *api.User) map[string]any {
91+
p := map[string]any{
92+
"email": u.Email,
93+
}
94+
if u.DisplayName != nil && *u.DisplayName != "" {
95+
p["name"] = *u.DisplayName
96+
}
97+
if u.Username != nil && *u.Username != "" {
98+
p["username"] = *u.Username
99+
}
100+
if u.CreatedAt != "" {
101+
// signup_date is immutable — Client uses $set_once for this key.
102+
p["signup_date"] = u.CreatedAt
103+
}
104+
return p
105+
}
106+
83107
// NewLoginCommand creates the login command.
84108
func NewLoginCommand() *cli.Command {
85109
return &cli.Command{

internal/telemetry/client.go

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,11 @@ type Client struct {
2828
machineIDHash string // anonymous distinct_id; never overwritten after Init
2929
globalProps posthog.Properties
3030

31-
mu sync.Mutex
32-
userID string // empty pre-login
33-
distinctID string // == machineIDHash pre-login, == userID post-login
34-
disabled bool
31+
mu sync.Mutex
32+
userID string // empty pre-login
33+
distinctID string // == machineIDHash pre-login, == userID post-login
34+
disabled bool
35+
personProps map[string]any // sent on Identify only; never on Capture events
3536
}
3637

3738
// silentLogger satisfies posthog.Logger but drops all output. Telemetry must
@@ -120,6 +121,26 @@ func (c *Client) Capture(event string, props map[string]any) {
120121
})
121122
}
122123

124+
// SetPersonProperties stores PostHog Person-level properties (email, name,
125+
// signup_date, ...) that will be attached to the next Identify event sent
126+
// by RebindIdentity. The map is held in memory only — it is never persisted
127+
// to disk and never appears on Capture event payloads.
128+
//
129+
// Callers (the login flow) MUST call this BEFORE RebindIdentity for the
130+
// props to land on the corresponding Identify.
131+
func (c *Client) SetPersonProperties(props map[string]any) {
132+
if c == nil || c.disabled {
133+
return
134+
}
135+
cp := make(map[string]any, len(props))
136+
for k, v := range props {
137+
cp[k] = v
138+
}
139+
c.mu.Lock()
140+
c.personProps = cp
141+
c.mu.Unlock()
142+
}
143+
123144
// RebindIdentity reads the on-disk Identity and aligns the client state.
124145
// Idempotent — repeated calls do nothing extra once user is bound.
125146
//
@@ -154,6 +175,26 @@ func (c *Client) RebindIdentity() {
154175
if v, ok := c.globalProps["channel"]; ok {
155176
identifyProps.Set("channel", v)
156177
}
178+
// Attach Person props (email, name, signup_date, ...) supplied by the
179+
// login flow. They go ONLY to PostHog's person record via Identify; they
180+
// are NOT included in any Capture event payload.
181+
c.mu.Lock()
182+
personProps := c.personProps
183+
c.mu.Unlock()
184+
setOnce := map[string]any{}
185+
for k, v := range personProps {
186+
switch k {
187+
case "signup_date", "created_at":
188+
// Immutable Person fields — only set on first identify per
189+
// person, never overwritten on subsequent logins.
190+
setOnce[k] = v
191+
default:
192+
identifyProps.Set(k, v)
193+
}
194+
}
195+
if len(setOnce) > 0 {
196+
identifyProps.Set("$set_once", setOnce)
197+
}
157198
_ = c.inner.Enqueue(posthog.Identify{
158199
DistinctId: id.UserID,
159200
Properties: identifyProps,

0 commit comments

Comments
 (0)