Skip to content

Commit 3fc9731

Browse files
authored
feat: Add device_id parameter to /flags requests (#157)
Add support for passing device_id to the PostHog /flags endpoint. This allows clients to identify devices separately from users when evaluating feature flags. - Add DeviceId field to FlagsRequestData (omitted when nil) - Add DeviceId to FeatureFlagPayload and FeatureFlagPayloadNoKey - Thread device_id through all flag evaluation paths - Add tests verifying device_id is passed in request payload
1 parent 634f3b8 commit 3fc9731

File tree

10 files changed

+433
-65
lines changed

10 files changed

+433
-65
lines changed

capture.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ type SendFeatureFlagsValue interface {
1414
type SendFeatureFlagsOptions struct {
1515
// OnlyEvaluateLocally forces evaluation to only use local flags and never make API requests
1616
OnlyEvaluateLocally bool
17+
// DeviceId provides a device_id for remote flag evaluation requests
18+
DeviceId *string
1719
// PersonProperties provides explicit person properties for local flag evaluation
1820
PersonProperties Properties
1921
// GroupProperties provides explicit group properties for local flag evaluation

feature_flag_config.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package posthog
33
type FeatureFlagPayload struct {
44
Key string
55
DistinctId string
6+
DeviceId *string
67
Groups Groups
78
PersonProperties Properties
89
GroupProperties map[string]Properties
@@ -48,6 +49,7 @@ func (c *FeatureFlagPayload) validate() error {
4849

4950
type FeatureFlagPayloadNoKey struct {
5051
DistinctId string
52+
DeviceId *string
5153
Groups Groups
5254
PersonProperties Properties
5355
GroupProperties map[string]Properties

feature_flags_flags_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,40 @@ func TestFlags(t *testing.T) {
209209
}
210210
}
211211

212+
func TestFeatureFlagCalledIncludesDeviceId(t *testing.T) {
213+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
214+
if strings.HasPrefix(r.URL.Path, "/flags") {
215+
w.Write([]byte(fixture("test-flags-v4.json")))
216+
}
217+
}))
218+
defer server.Close()
219+
220+
capture := &eventCapture{}
221+
client, _ := NewWithConfig("Csyjlnlun3OzyNJAafdlv", Config{
222+
Endpoint: server.URL,
223+
BatchSize: 1, // Send immediately
224+
Callback: capture,
225+
})
226+
defer client.Close()
227+
228+
deviceId := "device-123"
229+
_, _ = client.GetFeatureFlag(
230+
FeatureFlagPayload{
231+
Key: "enabled-flag",
232+
DistinctId: "some-distinct-id",
233+
DeviceId: &deviceId,
234+
},
235+
)
236+
237+
event := capture.waitForEvent(time.Second)
238+
if event == nil {
239+
t.Fatal("Expected a $feature_flag_called event, got nil")
240+
}
241+
if event.Properties["$device_id"] != deviceId {
242+
t.Errorf("Expected $device_id property to be %v, got: %v", deviceId, event.Properties["$device_id"])
243+
}
244+
}
245+
212246
func TestFeatureFlagErrorOnCapturedEvents(t *testing.T) {
213247
t.Run("success - no $feature_flag_error property", func(t *testing.T) {
214248
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

feature_flags_local_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4517,6 +4517,40 @@ func TestFeatureFlagDistinctIDOverride(t *testing.T) {
45174517
}
45184518
}
45194519

4520+
func TestFeatureFlagDeviceIDBucketingLocalEvaluation(t *testing.T) {
4521+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
4522+
if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {
4523+
w.Write([]byte(fixture("feature_flag/test-device-id-bucketing.json")))
4524+
} else if strings.HasPrefix(r.URL.Path, "/batch/") {
4525+
// ignore
4526+
} else {
4527+
t.Errorf("Unknown request made by library: %s", r.URL.String())
4528+
}
4529+
}))
4530+
defer server.Close()
4531+
4532+
client, _ := NewWithConfig("Csyjlnlun3OzyNJAafdlv", Config{
4533+
PersonalApiKey: "some very secret key",
4534+
Endpoint: server.URL,
4535+
})
4536+
defer client.Close()
4537+
4538+
distinctId := "distinct-123"
4539+
deviceId := "device-456"
4540+
expectedDevice := checkIfSimpleFlagEnabled("device-bucket-flag", deviceId, 50)
4541+
expectedDistinct := checkIfSimpleFlagEnabled("device-bucket-flag", distinctId, 50)
4542+
require.NotEqual(t, expectedDistinct, expectedDevice)
4543+
4544+
enabled, err := client.GetFeatureFlag(FeatureFlagPayload{
4545+
Key: "device-bucket-flag",
4546+
DistinctId: distinctId,
4547+
DeviceId: &deviceId,
4548+
OnlyEvaluateLocally: true,
4549+
})
4550+
require.NoError(t, err)
4551+
require.Equal(t, expectedDevice, enabled)
4552+
}
4553+
45204554
func TestFeatureFlagWithFalseVariant(t *testing.T) {
45214555
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
45224556
if strings.HasPrefix(r.URL.Path, "/api/feature_flag/local_evaluation") {

feature_flags_remote_test.go

Lines changed: 79 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ import (
44
"context"
55
"errors"
66
"fmt"
7+
"io"
78
"net"
89
"net/http"
910
"net/http/httptest"
10-
"strings"
1111
"testing"
12+
13+
json "github.com/goccy/go-json"
1214
)
1315

1416
func TestGetFeatureFlagFromRemote(t *testing.T) {
@@ -34,7 +36,7 @@ func TestGetFeatureFlagFromRemote(t *testing.T) {
3436
defer posthog.Close()
3537

3638
c := posthog.(*client)
37-
result := c.getFeatureFlagFromRemote("test-flag", "user-123", nil, nil, nil)
39+
result := c.getFeatureFlagFromRemote("test-flag", "user-123", nil, nil, nil, nil)
3840

3941
if result.Err != nil {
4042
t.Errorf("Expected no error, got: %v", result.Err)
@@ -74,7 +76,7 @@ func TestGetFeatureFlagFromRemote(t *testing.T) {
7476
defer posthog.Close()
7577

7678
c := posthog.(*client)
77-
result := c.getFeatureFlagFromRemote("variant-flag", "user-123", nil, nil, nil)
79+
result := c.getFeatureFlagFromRemote("variant-flag", "user-123", nil, nil, nil, nil)
7880

7981
if result.Err != nil {
8082
t.Errorf("Expected no error, got: %v", result.Err)
@@ -99,7 +101,7 @@ func TestGetFeatureFlagFromRemote(t *testing.T) {
99101
defer posthog.Close()
100102

101103
c := posthog.(*client)
102-
result := c.getFeatureFlagFromRemote("missing-flag", "user-123", nil, nil, nil)
104+
result := c.getFeatureFlagFromRemote("missing-flag", "user-123", nil, nil, nil, nil)
103105

104106
if result.Err != nil {
105107
t.Errorf("Expected no error, got: %v", result.Err)
@@ -133,7 +135,7 @@ func TestGetFeatureFlagFromRemote(t *testing.T) {
133135
defer posthog.Close()
134136

135137
c := posthog.(*client)
136-
result := c.getFeatureFlagFromRemote("test-flag", "user-123", nil, nil, nil)
138+
result := c.getFeatureFlagFromRemote("test-flag", "user-123", nil, nil, nil, nil)
137139

138140
if result.Err != nil {
139141
t.Errorf("Expected no error, got: %v", result.Err)
@@ -167,7 +169,7 @@ func TestGetFeatureFlagFromRemote(t *testing.T) {
167169
defer posthog.Close()
168170

169171
c := posthog.(*client)
170-
result := c.getFeatureFlagFromRemote("test-flag", "user-123", nil, nil, nil)
172+
result := c.getFeatureFlagFromRemote("test-flag", "user-123", nil, nil, nil, nil)
171173

172174
if result.Err != nil {
173175
t.Errorf("Expected no error, got: %v", result.Err)
@@ -189,7 +191,7 @@ func TestGetFeatureFlagFromRemote(t *testing.T) {
189191
defer posthog.Close()
190192

191193
c := posthog.(*client)
192-
result := c.getFeatureFlagFromRemote("test-flag", "user-123", nil, nil, nil)
194+
result := c.getFeatureFlagFromRemote("test-flag", "user-123", nil, nil, nil, nil)
193195

194196
if result.Err == nil {
195197
t.Error("Expected an error for failed HTTP request")
@@ -217,7 +219,7 @@ func TestGetFeatureFlagFromRemote(t *testing.T) {
217219
defer posthog.Close()
218220

219221
c := posthog.(*client)
220-
result := c.getFeatureFlagFromRemote("test-flag", "user-123", nil, nil, nil)
222+
result := c.getFeatureFlagFromRemote("test-flag", "user-123", nil, nil, nil, nil)
221223

222224
if result.Err == nil {
223225
t.Error("Expected an error for unauthorized request")
@@ -241,7 +243,7 @@ func TestGetFeatureFlagFromRemote(t *testing.T) {
241243
defer posthog.Close()
242244

243245
c := posthog.(*client)
244-
result := c.getFeatureFlagFromRemote("test-flag", "user-123", nil, nil, nil)
246+
result := c.getFeatureFlagFromRemote("test-flag", "user-123", nil, nil, nil, nil)
245247

246248
if result.Err == nil {
247249
t.Error("Expected an error for invalid JSON response")
@@ -255,7 +257,7 @@ func TestGetFeatureFlagFromRemote(t *testing.T) {
255257
defer posthog.Close()
256258

257259
c := posthog.(*client)
258-
result := c.getFeatureFlagFromRemote("test-flag", "user-123", nil, nil, nil)
260+
result := c.getFeatureFlagFromRemote("test-flag", "user-123", nil, nil, nil, nil)
259261

260262
if result.Err == nil {
261263
t.Error("Expected an error when server is unreachable")
@@ -314,11 +316,12 @@ func TestGetFeatureFlagFromRemote(t *testing.T) {
314316
})
315317

316318
t.Run("passes person properties in request", func(t *testing.T) {
317-
var receivedBody string
319+
var requestData FlagsRequestData
318320
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
319-
buf := make([]byte, 1024)
320-
n, _ := r.Body.Read(buf)
321-
receivedBody = string(buf[:n])
321+
body, _ := io.ReadAll(r.Body)
322+
if err := json.Unmarshal(body, &requestData); err != nil {
323+
t.Errorf("Failed to parse request body: %v", err)
324+
}
322325
w.Write([]byte(`{"flags": {}, "requestId": "req-props"}`))
323326
}))
324327
defer server.Close()
@@ -330,19 +333,20 @@ func TestGetFeatureFlagFromRemote(t *testing.T) {
330333

331334
c := posthog.(*client)
332335
personProps := NewProperties().Set("email", "test@example.com")
333-
c.getFeatureFlagFromRemote("test-flag", "user-123", nil, personProps, nil)
336+
c.getFeatureFlagFromRemote("test-flag", "user-123", nil, nil, personProps, nil)
334337

335-
if !strings.Contains(receivedBody, "test@example.com") {
336-
t.Errorf("Expected request body to contain person properties, got: %s", receivedBody)
338+
if requestData.PersonProperties["email"] != "test@example.com" {
339+
t.Errorf("Expected request body to contain person properties, got: %v", requestData.PersonProperties)
337340
}
338341
})
339342

340343
t.Run("passes groups in request", func(t *testing.T) {
341-
var receivedBody string
344+
var requestData FlagsRequestData
342345
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
343-
buf := make([]byte, 1024)
344-
n, _ := r.Body.Read(buf)
345-
receivedBody = string(buf[:n])
346+
body, _ := io.ReadAll(r.Body)
347+
if err := json.Unmarshal(body, &requestData); err != nil {
348+
t.Errorf("Failed to parse request body: %v", err)
349+
}
346350
w.Write([]byte(`{"flags": {}, "requestId": "req-groups"}`))
347351
}))
348352
defer server.Close()
@@ -354,10 +358,59 @@ func TestGetFeatureFlagFromRemote(t *testing.T) {
354358

355359
c := posthog.(*client)
356360
groups := Groups{"company": "posthog"}
357-
c.getFeatureFlagFromRemote("test-flag", "user-123", groups, nil, nil)
361+
c.getFeatureFlagFromRemote("test-flag", "user-123", nil, groups, nil, nil)
362+
363+
if requestData.Groups["company"] != "posthog" {
364+
t.Errorf("Expected request body to contain groups, got: %v", requestData.Groups)
365+
}
366+
})
367+
368+
t.Run("passes device_id in request when provided", func(t *testing.T) {
369+
var requestData FlagsRequestData
370+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
371+
body, _ := io.ReadAll(r.Body)
372+
if err := json.Unmarshal(body, &requestData); err != nil {
373+
t.Errorf("Failed to parse request body: %v", err)
374+
}
375+
w.Write([]byte(`{"flags": {}, "requestId": "req-device-id"}`))
376+
}))
377+
defer server.Close()
378+
379+
posthog, _ := NewWithConfig("test-api-key", Config{
380+
Endpoint: server.URL,
381+
})
382+
defer posthog.Close()
383+
384+
c := posthog.(*client)
385+
deviceId := "device-456"
386+
c.getFeatureFlagFromRemote("test-flag", "user-123", &deviceId, nil, nil, nil)
387+
388+
if requestData.DeviceId == nil || *requestData.DeviceId != "device-456" {
389+
t.Errorf("Expected request body to contain device_id, got: %v", requestData.DeviceId)
390+
}
391+
})
392+
393+
t.Run("omits device_id in request when nil", func(t *testing.T) {
394+
var requestData FlagsRequestData
395+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
396+
body, _ := io.ReadAll(r.Body)
397+
if err := json.Unmarshal(body, &requestData); err != nil {
398+
t.Errorf("Failed to parse request body: %v", err)
399+
}
400+
w.Write([]byte(`{"flags": {}, "requestId": "req-no-device-id"}`))
401+
}))
402+
defer server.Close()
403+
404+
posthog, _ := NewWithConfig("test-api-key", Config{
405+
Endpoint: server.URL,
406+
})
407+
defer posthog.Close()
408+
409+
c := posthog.(*client)
410+
c.getFeatureFlagFromRemote("test-flag", "user-123", nil, nil, nil, nil)
358411

359-
if !strings.Contains(receivedBody, "posthog") {
360-
t.Errorf("Expected request body to contain groups, got: %s", receivedBody)
412+
if requestData.DeviceId != nil {
413+
t.Errorf("Expected request body to NOT contain device_id when nil, got: %v", requestData.DeviceId)
361414
}
362415
})
363416
}
@@ -588,7 +641,7 @@ func TestFailedFlagShouldNotReturnValue(t *testing.T) {
588641
defer posthog.Close()
589642

590643
c := posthog.(*client)
591-
result := c.getFeatureFlagFromRemote("test-flag", "user-123", nil, nil, nil)
644+
result := c.getFeatureFlagFromRemote("test-flag", "user-123", nil, nil, nil, nil)
592645

593646
if result.Err != nil {
594647
t.Errorf("Expected no error, got: %v", result.Err)
@@ -638,7 +691,7 @@ func TestFailedFlagShouldNotReturnValue(t *testing.T) {
638691
defer posthog.Close()
639692

640693
c := posthog.(*client)
641-
result := c.getFeatureFlagFromRemote("test-flag", "user-123", nil, nil, nil)
694+
result := c.getFeatureFlagFromRemote("test-flag", "user-123", nil, nil, nil, nil)
642695

643696
if result.Err != nil {
644697
t.Errorf("Expected no error, got: %v", result.Err)

0 commit comments

Comments
 (0)