diff --git a/.changeset/tidy-otters-listen.md b/.changeset/tidy-otters-listen.md new file mode 100644 index 0000000..56b6890 --- /dev/null +++ b/.changeset/tidy-otters-listen.md @@ -0,0 +1,5 @@ +--- +"posthog-go": patch +--- + +Omit null-valued custom event object properties recursively while preserving array positions, supported JSON values, and typed flag and exception metadata. diff --git a/capture_v1.go b/capture_v1.go index 3e06f86..87c938f 100644 --- a/capture_v1.go +++ b/capture_v1.go @@ -257,9 +257,22 @@ func baseV1Props(isServer bool, disableGeoIP bool) Properties { func prepareForSendV1(msg Message, logger Logger) (json.RawMessage, APIMessage, string, error) { apiMsg := msg.APIfy() ev := buildV1Event(msg.apifyEvent(), logger) - data, err := json.Marshal(ev) + props := flagEventProperties(ev.Event, ev.Properties) + if exception, ok := apiMsg.(ExceptionInApi); ok { + props.preserve = []string{"$exception_list"} + if exception.Properties.ExceptionFingerprint != nil { + props.preserve = append(props.preserve, "$exception_fingerprint") + } + if len(exception.Properties.DebugImages) > 0 { + props.preserve = append(props.preserve, "$debug_images") + } + } + data, err := json.Marshal(struct { + eventPayload + Properties eventProperties `json:"properties"` + }{ev, props}) if err != nil { - return nil, apiMsg, ev.Uuid, err + return nil, apiMsg, ev.Uuid, eventPropertySerializationError(err) } return json.RawMessage(data), apiMsg, ev.Uuid, nil } diff --git a/error_tracking.go b/error_tracking.go index c405456..b544e24 100644 --- a/error_tracking.go +++ b/error_tracking.go @@ -175,11 +175,13 @@ func (p ExceptionInApiProperties) MarshalJSON() ([]byte, error) { if _, exists := merged[k]; exists { continue } - vb, err := json.Marshal(v) + vb, err := marshalProperty(v) if err != nil { return nil, err } - merged[k] = vb + if string(vb) != "null" { + merged[k] = vb + } } return json.Marshal(merged) diff --git a/fixtures/test-enqueue-capture-zero-values.json b/fixtures/test-enqueue-capture-zero-values.json index a2c6fed..cc57b31 100644 --- a/fixtures/test-enqueue-capture-zero-values.json +++ b/fixtures/test-enqueue-capture-zero-values.json @@ -14,8 +14,7 @@ "empty_array": [], "empty_object": {}, "empty_string": "", - "false_value": false, - "nil_value": null + "false_value": false }, "timestamp": "2009-11-10T23:00:00Z", "uuid": "00000000-0000-0000-0000-000000000012" diff --git a/flag_property_serialization_test.go b/flag_property_serialization_test.go new file mode 100644 index 0000000..cdc970d --- /dev/null +++ b/flag_property_serialization_test.go @@ -0,0 +1,195 @@ +package posthog + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + json "github.com/goccy/go-json" +) + +// Exercise both flag producers through their real evaluation and capture HTTP paths. +func TestFlagPropertySerializationWire(t *testing.T) { + for _, mode := range []CaptureMode{CaptureModeLegacy, CaptureModeAnalyticsV1} { + for _, snapshot := range []bool{false, true} { + t.Run(fmt.Sprintf("%d/snapshot=%v", mode, snapshot), func(t *testing.T) { + bodies := make(chan []byte, 4) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if strings.HasPrefix(r.URL.Path, "/flags") { + _, _ = w.Write([]byte(`{"flags":{"plain":{"key":"plain","enabled":false,"metadata":{"id":1,"version":1,"has_experiment":false}}},"minimalFlagCalledEvents":true,"errorsWhileComputingFlags":true}`)) + return + } + body, _ := io.ReadAll(r.Body) + bodies <- body + _, _ = w.Write([]byte(`{"results":{}}`)) + })) + defer server.Close() + client, err := NewWithConfig("test-key", Config{ + Endpoint: server.URL, CaptureMode: mode, Interval: time.Hour, + Transport: loopbackPropertyTransport{server.URL, server.Client().Transport}, + BeforeSend: func(m Message) Message { + if c, ok := m.(Capture); ok { + c.Properties["customNull"] = nil + c.Properties["customKeep"] = true + c.Properties["$feature/other"] = nil + c.Properties["$feature_flag_response_extra"] = nil + c.Properties["items"] = []interface{}{nil, Properties{"drop": nil}} + return c + } + return m + }, + }) + if err != nil { + t.Fatal(err) + } + defer client.Close() + if snapshot { + flags, err := client.EvaluateFlags(EvaluateFlagsPayload{DistinctId: "test-user"}) + if err != nil { + t.Fatal(err) + } + if flags.IsEnabled("missing") || flags.IsEnabled("plain") { + t.Fatal("unexpected enabled flag") + } + } else { + result, err := client.GetFeatureFlagResult(FeatureFlagPayload{Key: "missing", DistinctId: "test-user"}) + if result != nil || err == nil { + t.Fatalf("missing result: %v %v", result, err) + } + result, err = client.GetFeatureFlagResult(FeatureFlagPayload{Key: "plain", DistinctId: "test-user"}) + if err != nil || result == nil || result.Enabled { + t.Fatalf("plain result: %v %v", result, err) + } + } + if err := client.Enqueue(Capture{Event: "ordinary", DistinctId: "test-user", Properties: Properties{ + "$feature_flag": "missing", "$feature_flag_response": nil, "$feature/missing": nil, + }}); err != nil { + t.Fatal(err) + } + if err := client.Close(); err != nil { + t.Fatal(err) + } + count := 0 + for len(bodies) > 0 { + var batch struct { + Batch []struct { + Event string `json:"event"` + Properties map[string]json.RawMessage `json:"properties"` + } `json:"batch"` + } + if err := json.Unmarshal(<-bodies, &batch); err != nil { + t.Fatal(err) + } + for _, e := range batch.Batch { + count++ + p := e.Properties + for _, key := range []string{"customNull", "$feature/other", "$feature_flag_response_extra"} { + if _, ok := p[key]; ok { + t.Errorf("custom key %s retained: %s", key, p[key]) + } + } + if e.Event == "ordinary" { + for _, key := range []string{"$feature_flag_response", "$feature/missing"} { + if _, ok := p[key]; ok { + t.Errorf("ordinary event retained %s", key) + } + } + } else if string(p["$feature_flag"]) == `"missing"` { + if string(p["$feature_flag_response"]) != "null" { + t.Errorf("generated null response missing: %v", p) + } + if snapshot && string(p["$feature/missing"]) != "null" { + t.Errorf("generated exact feature null missing: %v", p) + } + if !snapshot { + if _, ok := p["$feature/missing"]; ok { + t.Error("invented feature field") + } + } + if !strings.Contains(string(p["$feature_flag_error"]), "flag_missing") { + t.Error("missing flag error lost") + } + if !strings.Contains(string(p["$feature_flag_error"]), "errors_while_computing_flags") { + t.Error("evaluation error lost") + } + if string(p["items"]) != `[null,{}]` || string(p["customKeep"]) != "true" { + t.Errorf("custom siblings changed: %v", p) + } + } else { + if string(p["$feature_flag_response"]) != "false" { + t.Errorf("false response changed: %v", p) + } + for _, key := range []string{"$feature/plain", "items", "customKeep"} { + if _, ok := p[key]; ok { + t.Errorf("minimal privacy boundary bypassed: %s", key) + } + } + } + } + } + if count != 3 { + t.Fatalf("got %d events, want 3", count) + } + }) + } + } +} + +func TestFlagPropertySerializationScope(t *testing.T) { + for _, v1 := range []bool{false, true} { + for _, minimal := range []bool{false, true} { + msg := Capture{Event: "$feature_flag_called", DistinctId: "test-user", minimalFlagCalledEvent: minimal, Properties: Properties{ + "$feature_flag": "missing", "$feature_flag_response": nil, "$feature/missing": nil, + "$feature/other": nil, "custom": nil, + }} + var data json.RawMessage + var err error + if v1 { + data, _, _, err = prepareForSendV1(msg, nil) + } else { + data, _, err = prepareForSend(msg) + } + if err != nil { + t.Fatal(err) + } + var e struct { + Properties map[string]json.RawMessage `json:"properties"` + } + if err := json.Unmarshal(data, &e); err != nil { + t.Fatal(err) + } + if string(e.Properties["$feature_flag_response"]) != "null" { + t.Errorf("v1=%v minimal=%v response missing: %s", v1, minimal, data) + } + _, hasFeature := e.Properties["$feature/missing"] + if hasFeature == minimal { + t.Errorf("privacy boundary changed: %s", data) + } + for _, key := range []string{"$feature/other", "custom"} { + if _, ok := e.Properties[key]; ok { + t.Errorf("custom key retained: %s", data) + } + } + } + } + // Non-null custom objects in typed-looking fields still normalize recursively. + data, err := json.Marshal(flagEventProperties("$feature_flag_called", Properties{ + "$feature_flag": "key", "$feature_flag_response": Properties{"drop": nil}, + "$feature/key": Properties{"items": []interface{}{nil, Properties{"drop": nil}}}, + })) + if err != nil { + t.Fatal(err) + } + var p map[string]json.RawMessage + if err := json.Unmarshal(data, &p); err != nil { + t.Fatal(err) + } + if string(p["$feature_flag_response"]) != "{}" || string(p["$feature/key"]) != `{"items":[null,{}]}` { + t.Fatalf("broad typed-field exemption: %s", data) + } +} diff --git a/message.go b/message.go index a53546e..c2ddef1 100644 --- a/message.go +++ b/message.go @@ -102,9 +102,9 @@ type APIMessage interface{} // Size is derived from len(json.RawMessage) when needed - O(1) operation. func prepareForSend(msg Message) (json.RawMessage, APIMessage, error) { apiMsg := msg.APIfy() - data, err := json.Marshal(apiMsg) + data, err := marshalAPIEvent(apiMsg) if err != nil { - return nil, apiMsg, err + return nil, apiMsg, eventPropertySerializationError(err) } return json.RawMessage(data), apiMsg, nil } diff --git a/property_serialization.go b/property_serialization.go new file mode 100644 index 0000000..9a1c015 --- /dev/null +++ b/property_serialization.go @@ -0,0 +1,161 @@ +package posthog + +import ( + "bytes" + "reflect" + + json "github.com/goccy/go-json" +) + +// marshalProperty applies the event-property policy to the JSON representation, +// not the Go value: typed nils, structs and custom marshalers follow the same +// rules. Token traversal preserves ordered duplicate members; UseNumber keeps +// numeric tokens intact. Only the private output buffer is changed. +func marshalProperty(value interface{}) ([]byte, error) { + data, err := json.Marshal(value) + if err != nil { + return nil, err + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + return appendPropertyJSON(make([]byte, 0, len(data)), decoder) +} + +// The input has already been validated by json.Marshal, including custom JSON. +// Append each occurrence independently, rolling back only null object members. +func appendPropertyJSON(data []byte, decoder *json.Decoder) ([]byte, error) { + token, err := decoder.Token() + if err != nil { + return nil, err + } + delim, container := token.(json.Delim) + if !container { + scalar, err := json.Marshal(token) + return append(data, scalar...), err + } + data = append(data, byte(delim)) + start := len(data) + for decoder.More() { + memberStart := len(data) + if memberStart > start { + data = append(data, ',') + } + if delim == '{' { + key, err := decoder.Token() + if err != nil { + return nil, err + } + encodedKey, err := json.Marshal(key) + if err != nil { + return nil, err + } + data = append(data, encodedKey...) + data = append(data, ':') + } + valueStart := len(data) + data, err = appendPropertyJSON(data, decoder) + if err != nil { + return nil, err + } + if delim == '{' && string(data[valueStart:]) == "null" { + data = data[:memberStart] + } + } + end, err := decoder.Token() + if err != nil { + return nil, err + } + return append(data, byte(end.(json.Delim))), nil +} + +// eventProperties is a wire-only wrapper, deliberately not a MarshalJSON method +// on public Properties: feature flag requests, caches and other JSON are outside +// the event-property contract. preserve names typed metadata already installed +// by event producers; custom $set/$group_set are never exempt. +type eventProperties struct { + values Properties + preserve []string +} + +func (p eventProperties) MarshalJSON() ([]byte, error) { + if len(p.preserve) == 0 { + return marshalProperty(p.values) + } + custom := make(Properties, len(p.values)) + for key, value := range p.values { + custom[key] = value + } + for _, key := range p.preserve { + delete(custom, key) + } + data, err := marshalProperty(custom) + if err != nil { + return nil, err + } + var merged map[string]json.RawMessage + if err := json.Unmarshal(data, &merged); err != nil { + return nil, err + } + for _, key := range p.preserve { + if value, ok := p.values[key]; ok { + data, err := json.Marshal(value) + if err != nil { + return nil, err + } + merged[key] = data + } + } + return json.Marshal(merged) +} + +// Flag producers deliberately emit nil responses for missing/error evaluations. +// Preserve only those root nulls on their event, and only the exact evaluated +// feature key when present. Never restore fields removed by the privacy allowlist. +func flagEventProperties(event string, values Properties) eventProperties { + props := eventProperties{values: values} + if event == "$feature_flag_called" { + keys := []string{"$feature_flag_response"} + if key, ok := values["$feature_flag"].(string); ok { + keys = append(keys, "$feature/"+key) + } + for _, key := range keys { + if value, ok := values[key]; ok && value == nil { + props.preserve = append(props.preserve, key) + } + } + } + return props +} + +// Remove only the wrapper introduced by our private wire adapter. In particular, +// keep user marshaler errors and the legacy exception metadata wrapper intact. +func eventPropertySerializationError(err error) error { + if wrapped, ok := err.(*json.MarshalerError); ok && wrapped.Type == reflect.TypeOf(eventProperties{}) { + return wrapped.Err + } + return err +} + +func marshalAPIEvent(apiMsg APIMessage) ([]byte, error) { + switch msg := apiMsg.(type) { + case CaptureInApi: + return json.Marshal(struct { + CaptureInApi + Properties eventProperties `json:"properties"` + }{msg, flagEventProperties(msg.Event, msg.Properties)}) + case IdentifyInApi: + return json.Marshal(struct { + IdentifyInApi + Set eventProperties `json:"$set"` + }{msg, eventProperties{values: msg.Set}}) + case GroupIdentifyInApi: + return json.Marshal(struct { + GroupIdentifyInApi + Properties eventProperties `json:"properties"` + }{msg, eventProperties{values: msg.Properties}}) + default: + // ExceptionInApiProperties flattens and normalizes only Custom, leaving its + // typed metadata alone. Alias has no caller-supplied property subtree. + return json.Marshal(apiMsg) + } +} diff --git a/property_serialization_test.go b/property_serialization_test.go new file mode 100644 index 0000000..51cf59b --- /dev/null +++ b/property_serialization_test.go @@ -0,0 +1,483 @@ +package posthog + +import ( + "bytes" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "reflect" + "testing" + "time" + + json "github.com/goccy/go-json" +) + +type nullPropertyMarshaler struct{} + +func (nullPropertyMarshaler) MarshalJSON() ([]byte, error) { return []byte("null"), nil } + +type objectPropertyMarshaler struct{} + +func (objectPropertyMarshaler) MarshalJSON() ([]byte, error) { + return []byte(`{"drop":null,"large":9007199254740993}`), nil +} + +type orderedPropertyMarshaler struct { + data string + calls int +} + +func (m *orderedPropertyMarshaler) MarshalJSON() ([]byte, error) { + m.calls++ + return []byte(m.data), nil +} + +// Compare raw property bytes: decoding the tested objects into maps would hide +// duplicate-member loss in the event serializer itself. +func TestEventPropertySerializationOrderedMembers(t *testing.T) { + cases := []struct { + name, input, want string + }{ + {"nonnull_duplicates", `{"x":1,"x":2,"drop":null}`, `{"x":1,"x":2}`}, + {"trailing_null", `{"x":1,"x":null}`, `{"x":1}`}, + {"leading_null", `{"x":null,"x":2}`, `{"x":2}`}, + {"all_null", `{"x":null,"x":null}`, `{}`}, + {"case_distinct", `{"Foo":1,"foo":2,"Foo":null,"FOO":3}`, `{"Foo":1,"foo":2,"FOO":3}`}, + {"escaped_keys", `{"x":1,"\u0078":null,"x":2,"quote\"":"null","\\":false}`, `{"x":1,"x":2,"quote\"":"null","\\":false}`}, + {"arrays_and_numbers", `{"items":[null,{"x":9007199254740993,"x":18446744073709551615,"x":null},[null,{"n":1.234567890123456789,"n":1e300,"n":-0,"n":1.2300e+04,"drop":null}],{},[],false,0,""]}`, `{"items":[null,{"x":9007199254740993,"x":18446744073709551615},[null,{"n":1.234567890123456789,"n":1e300,"n":-0,"n":1.2300e+04}],{},[],false,0,""]}`}, + } + for _, v1 := range []bool{false, true} { + for _, kind := range []string{"capture", "flag", "exception"} { + for _, custom := range []bool{false, true} { + for _, tc := range cases { + t.Run(fmt.Sprintf("v1=%v/%s/custom=%v/%s", v1, kind, custom, tc.name), func(t *testing.T) { + marshaler := &orderedPropertyMarshaler{data: tc.input} + raw := json.RawMessage(tc.input) + var value interface{} = raw + if custom { + value = marshaler + } + props := Properties{"ordered": value} + plain, err := json.Marshal(props) + if err != nil || string(plain) != `{"ordered":`+tc.input+`}` { + t.Fatalf("generic Properties changed: %s, %v", plain, err) + } + marshaler.calls = 0 + var msg Message = Capture{Event: "ordinary", DistinctId: "test-user", Properties: props} + if kind == "flag" { + props["$feature_flag"] = "missing" + props["$feature_flag_response"] = nil + props["$feature/missing"] = nil + msg = Capture{Event: "$feature_flag_called", DistinctId: "test-user", Properties: props} + } else if kind == "exception" { + msg = Exception{DistinctId: "test-user", Properties: props, ExceptionList: []ExceptionItem{{Type: "Test", Value: "test"}}} + } + var data json.RawMessage + if v1 { + data, _, _, err = prepareForSendV1(msg, nil) + } else { + data, _, err = prepareForSend(msg) + } + if err != nil { + t.Fatal(err) + } + var envelope struct { + Properties struct { + Ordered json.RawMessage `json:"ordered"` + Response json.RawMessage `json:"$feature_flag_response"` + Feature json.RawMessage `json:"$feature/missing"` + } `json:"properties"` + } + if err := json.Unmarshal(data, &envelope); err != nil { + t.Fatal(err) + } + if got := string(envelope.Properties.Ordered); got != tc.want { + t.Errorf("ordered bytes: got %s want %s", got, tc.want) + } + if kind == "flag" && (string(envelope.Properties.Response) != "null" || string(envelope.Properties.Feature) != "null") { + t.Errorf("typed flag nulls changed: %s", data) + } + if custom && marshaler.calls != 1 { + t.Errorf("MarshalJSON called %d times, want once", marshaler.calls) + } + if string(raw) != tc.input || marshaler.data != tc.input { + t.Fatal("caller JSON mutated") + } + }) + } + } + } + } +} + +func TestEventPropertySerializationRejectedJSON(t *testing.T) { + for _, v1 := range []bool{false, true} { + for _, custom := range []bool{false, true} { + // The existing JSON marshaler also rejects out-of-range raw numbers. + for _, input := range []string{`{"x":`, `{"x":1e400}`} { + t.Run(fmt.Sprintf("v1=%v/custom=%v/%s", v1, custom, input), func(t *testing.T) { + var value interface{} = json.RawMessage(input) + if custom { + value = &orderedPropertyMarshaler{data: input} + } + if _, err := json.Marshal(value); err == nil { + t.Fatal("fixture must fail ordinary serialization") + } + msg := Capture{Event: "ordinary", DistinctId: "test-user", Properties: Properties{"invalid": value}} + var err error + if v1 { + _, _, _, err = prepareForSendV1(msg, nil) + } else { + _, _, err = prepareForSend(msg) + } + if err == nil { + t.Fatal("rejected JSON must still report a serialization error") + } + }) + } + } + } +} + +type errorPropertyMarshaler struct{ err error } + +func (m errorPropertyMarshaler) MarshalJSON() ([]byte, error) { return nil, m.err } + +// Compare the original serializer's concrete error chain, not only errors.As: +// the private property adapter must not add a callback-visible wrapper, and +// user-provided MarshalJSON error layers must remain intact. +func TestEventPropertySerializationErrorCompatibility(t *testing.T) { + sentinel := errors.New("custom property serialization failed") + userWrapped := &json.MarshalerError{Type: reflect.TypeOf(""), Err: sentinel} + for _, mode := range []CaptureMode{CaptureModeLegacy, CaptureModeAnalyticsV1} { + for _, kind := range []string{"capture", "identify", "group", "exception"} { + for _, tc := range []struct { + name string + value interface{} + cause error + }{ + {"unsupported", func() {}, nil}, + {"custom", errorPropertyMarshaler{sentinel}, sentinel}, + {"custom_wrapped", errorPropertyMarshaler{userWrapped}, userWrapped}, + } { + t.Run(fmt.Sprintf("%d/%s/%s", mode, kind, tc.name), func(t *testing.T) { + props := Properties{"invalid": tc.value} + var msg Message + switch kind { + case "capture": + msg = Capture{Event: "test", DistinctId: "test-user", Properties: props} + case "identify": + msg = Identify{DistinctId: "test-user", Properties: props} + case "group": + msg = GroupIdentify{Type: "company", Key: "test", Properties: props} + case "exception": + msg = Exception{DistinctId: "test-user", Properties: props, ExceptionList: []ExceptionItem{{Type: "Test", Value: "test"}}} + } + var baseline, actual error + if mode == CaptureModeAnalyticsV1 { + _, baseline = json.Marshal(buildV1Event(msg.apifyEvent(), nil)) + _, _, _, actual = prepareForSendV1(msg, nil) + } else { + _, baseline = json.Marshal(msg.APIfy()) + _, _, actual = prepareForSend(msg) + } + if baseline == nil { + t.Fatal("fixture must fail original serialization") + } + check := func(label string, got error) { + t.Helper() + for want := baseline; want != nil; want = errors.Unwrap(want) { + if reflect.TypeOf(got) != reflect.TypeOf(want) || got.Error() != want.Error() { + t.Errorf("%s error: got %T %v, want %T %v", label, got, got, want, want) + return + } + if want == tc.cause && got != tc.cause { + t.Errorf("%s replaced user error identity", label) + } + got = errors.Unwrap(got) + } + if got != nil { + t.Errorf("%s added error layer: %v", label, got) + } + } + check("prepare", actual) + failures := make(chan error, 1) + client, err := NewWithConfig("test-key", Config{ + CaptureMode: mode, Transport: testTransportOK, + Callback: testCallback{nil, func(_ APIMessage, err error) { failures <- err }}, + }) + if err != nil { + t.Fatal(err) + } + if err := client.Enqueue(msg); err != nil { + t.Error(err) + } + if err := client.Close(); err != nil { + t.Error(err) + } + select { + case err := <-failures: + check("callback", err) + default: + t.Error("failure callback not triggered") + } + }) + } + } + } +} + +func nullPropertyFixture() Properties { + var ptr *string + var m map[string]interface{} + var s []interface{} + return Properties{ + "test": nil, "pointer": ptr, "nilMap": m, "nilSlice": s, + "rawNull": json.RawMessage(" null "), "customNull": nullPropertyMarshaler{}, + "nested": map[string]*string{"drop": nil}, + "struct": struct { + Drop *string `json:"drop"` + Keep int `json:"keep"` + }{Keep: 1}, + "raw": json.RawMessage(`{"drop":null,"items":[null,{"drop":null}],"large":9007199254740993,"decimal":1.234567890123456789}`), + "custom": objectPropertyMarshaler{}, + "items": []interface{}{"1", nil, 2, Properties{"drop": nil}, []interface{}{nil}}, + "emptyObject": Properties{}, "emptyArray": []interface{}{}, "empty": "", "zero": 0, "enabled": false, + "literal": "null", "literalUndefined": "undefined", "large": uint64(18446744073709551615), + "$set": Properties{"drop": nil}, "$group_set": Properties{"drop": nil}, + } +} + +func assertNullProperties(t *testing.T, raw json.RawMessage) { + t.Helper() + var got map[string]json.RawMessage + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatal(err) + } + for _, key := range []string{"test", "pointer", "nilMap", "nilSlice", "rawNull", "customNull", "missing", "hookNull"} { + if _, ok := got[key]; ok { + t.Errorf("%s must be absent: %s", key, got[key]) + } + } + expected := map[string]string{ + "nested": `{}`, "struct": `{"keep":1}`, "raw": `{"items":[null,{}],"large":9007199254740993,"decimal":1.234567890123456789}`, + "custom": `{"large":9007199254740993}`, "items": `["1",null,2,{},[null]]`, "emptyObject": `{}`, "emptyArray": `[]`, + "empty": `""`, "zero": `0`, "enabled": `false`, "literal": `"null"`, "literalUndefined": `"undefined"`, + "large": `18446744073709551615`, "$set": `{}`, "$group_set": `{}`, + } + for k, want := range expected { + var actualValue, wantValue interface{} + a := json.NewDecoder(bytes.NewReader(got[k])) + a.UseNumber() + b := json.NewDecoder(bytes.NewBufferString(want)) + b.UseNumber() + if err := a.Decode(&actualValue); err != nil { + t.Errorf("%s: %v", k, err) + continue + } + _ = b.Decode(&wantValue) + if !reflect.DeepEqual(actualValue, wantValue) { + t.Errorf("%s: got %s want %s", k, got[k], want) + } + } +} + +func TestEventPropertySerialization(t *testing.T) { + for _, v1 := range []bool{false, true} { + for _, kind := range []string{"capture", "exception", "identify", "group"} { + t.Run(fmt.Sprintf("v1=%v/%s", v1, kind), func(t *testing.T) { + props := nullPropertyFixture() + before, _ := json.Marshal(props) + var msg Message + switch kind { + case "capture": + msg = Capture{Event: "$ai_generation", DistinctId: "test-user", Properties: props} + case "exception": + msg = Exception{DistinctId: "test-user", Properties: props, ExceptionList: []ExceptionItem{{Type: "Test", Value: "test", Stacktrace: &ExceptionStacktrace{Type: "raw"}}}} + case "identify": + msg = Identify{DistinctId: "test-user", Properties: props} + case "group": + msg = GroupIdentify{Type: "company", Key: "test", Properties: props} + } + var data json.RawMessage + var err error + if v1 { + data, _, _, err = prepareForSendV1(msg, nil) + } else { + data, _, err = prepareForSend(msg) + } + if err != nil { + t.Fatal(err) + } + var envelope map[string]json.RawMessage + _ = json.Unmarshal(data, &envelope) + raw := envelope["properties"] + if kind == "identify" && !v1 { + raw = envelope["$set"] + } else if kind == "identify" || kind == "group" { + var p map[string]json.RawMessage + _ = json.Unmarshal(raw, &p) + if kind == "identify" { + raw = p["$set"] + } else { + raw = p["$group_set"] + } + } + assertNullProperties(t, raw) + if kind == "exception" && !bytes.Contains(data, []byte(`"frames":null`)) { + t.Errorf("typed exception metadata changed: %s", data) + } + after, _ := json.Marshal(props) + if !bytes.Equal(before, after) { + t.Fatal("caller properties mutated") + } + }) + } + } +} + +type loopbackPropertyTransport struct { + url string + transport http.RoundTripper +} + +func (g loopbackPropertyTransport) RoundTrip(r *http.Request) (*http.Response, error) { + if r.URL.Scheme+"://"+r.URL.Host != g.url { + return nil, fmt.Errorf("non-test SDK request blocked: %s", r.URL) + } + return g.transport.RoundTrip(r) +} + +func TestEventPropertySerializationWire(t *testing.T) { + for _, mode := range []CaptureMode{CaptureModeLegacy, CaptureModeAnalyticsV1} { + for _, hook := range []bool{false, true} { + t.Run(fmt.Sprintf("%d/hook=%v", mode, hook), func(t *testing.T) { + bodies := make(chan []byte, 4) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + bodies <- body + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"results":{}}`)) + })) + defer server.Close() + cfg := Config{Endpoint: server.URL, CaptureMode: mode, Interval: time.Hour, Transport: loopbackPropertyTransport{server.URL, server.Client().Transport}} + if hook { + cfg.BeforeSend = func(m Message) Message { + if c, ok := m.(Capture); ok { + if c.Event == "drop" { + return nil + } + c.Properties["hookNull"] = nil + c.Properties["hookItems"] = []interface{}{nil, Properties{"drop": nil}} + return c + } + return m + } + } + client, err := NewWithConfig("test-key", cfg) + if err != nil { + t.Fatal(err) + } + props := nullPropertyFixture() + // Existing hook isolation turns common nil maps/slices into empty containers. + if hook { + delete(props, "nilMap") + delete(props, "nilSlice") + props["hookNilMap"] = map[string]interface{}(nil) + props["hookNilSlice"] = []interface{}(nil) + } + before, _ := json.Marshal(props) + for _, m := range []Message{Capture{Event: "test", DistinctId: "test-user", Properties: props}, Capture{Event: "only", DistinctId: "test-user", Properties: Properties{"test": nil}}, Exception{DistinctId: "test-user", Properties: props, ExceptionList: []ExceptionItem{{Type: "Test", Value: "test"}}}} { + if err := client.Enqueue(m); err != nil { + t.Fatal(err) + } + } + if hook { + _ = client.Enqueue(Capture{Event: "drop", DistinctId: "test-user"}) + } + if err := client.Close(); err != nil { + t.Fatal(err) + } + count := 0 + for len(bodies) > 0 { + var batch struct { + Batch []struct { + Event string `json:"event"` + Properties json.RawMessage `json:"properties"` + } `json:"batch"` + } + if err := json.Unmarshal(<-bodies, &batch); err != nil { + t.Fatal(err) + } + for _, e := range batch.Batch { + count++ + if e.Event == "only" { + var p map[string]json.RawMessage + _ = json.Unmarshal(e.Properties, &p) + if _, ok := p["test"]; ok { + t.Error("null-only property retained") + } + continue + } + assertNullProperties(t, e.Properties) + if hook { + var p map[string]json.RawMessage + _ = json.Unmarshal(e.Properties, &p) + if string(p["hookNilMap"]) != "{}" || string(p["hookNilSlice"]) != "[]" { + t.Errorf("existing hook clone container semantics changed: %s", e.Properties) + } + } + if hook && e.Event == "test" { + var p map[string]json.RawMessage + _ = json.Unmarshal(e.Properties, &p) + if string(p["hookItems"]) != `[null,{}]` { + t.Errorf("hook items: %s", p["hookItems"]) + } + } + } + } + if count != 3 { + t.Errorf("got %d events, want 3", count) + } + after, _ := json.Marshal(props) + if !bytes.Equal(before, after) { + t.Fatal("wire serialization or hook mutated caller properties") + } + }) + } + } +} + +func TestEventPropertySerializationScopeAndErrors(t *testing.T) { + props := Properties{"test": nil, "nested": Properties{"drop": nil}} + plain, err := json.Marshal(props) + if err != nil || !bytes.Contains(plain, []byte(`"test":null`)) { + t.Fatalf("public Properties JSON changed: %s %v", plain, err) + } + for _, v1 := range []bool{false, true} { + msg := Capture{Event: "$feature_flag_called", DistinctId: "test-user", Properties: props, minimalFlagCalledEvent: true} + var data json.RawMessage + if v1 { + data, _, _, err = prepareForSendV1(msg, nil) + } else { + data, _, err = prepareForSend(msg) + } + if err != nil { + t.Fatal(err) + } + if bytes.Contains(data, []byte(`"nested"`)) { + t.Fatalf("minimal-event allowlist bypassed: %s", data) + } + invalid := Capture{Event: "test", DistinctId: "test-user", Properties: Properties{"unsupported": make(chan int)}} + if v1 { + _, _, _, err = prepareForSendV1(invalid, nil) + } else { + _, _, err = prepareForSend(invalid) + } + if err == nil { + t.Fatal("unsupported values must still report serialization errors") + } + } +}