diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 5b0a72e..5e41c9a 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -71,7 +71,7 @@ concurrency: env: # invisible-tools/raindrop-sdk-harness — see "Harness pin" in the header. HARNESS_REPO: invisible-tools/raindrop-sdk-harness - HARNESS_REF: cf744e9c53185c1fd6c350888f64a016b46a10a3 # main @ 2026-07-14 (signal capability active + signal scenarios, DEV-1201) + HARNESS_REF: 6bd23884ce390dd56b29d4ba32277a2d9d9ec0a8 # main @ 2026-07-14 (feature_flags scenarios stable, DEV-1210/DEV-1214) SERVER_URL: http://127.0.0.1:8787 # Driver binary produced by `go build -o conformance/driver ./conformance` # (conformance/ is a stdlib-only package inside this module, so the driver diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f0e8aa1 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/conformance/driver diff --git a/README.md b/README.md index 0e0acf0..a12dba2 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,28 @@ _ = client.TrackEvent(ctx, raindrop.Event{ }) ``` +### Feature Flags + +Attach the feature-flag variants active when an event happened with the +optional `FeatureFlags` (a `map[string]string`). It is available on the +`Event`, `AIEvent`, `BeginOptions`, `PatchOptions`, and `FinishOptions` +surfaces (and via `interaction.SetFeatureFlags`), and serializes to the +top-level `feature_flags` object on the wire. Omit it and no key is sent. + +```go +_ = client.TrackAI(ctx, raindrop.AIEvent{ + UserID: "user-123", + Event: "chat_message", + Input: "How do I enable reasoning?", + Output: "Toggle it in Settings.", + Model: "gpt-4o", + FeatureFlags: map[string]string{ + "prompt-version": "v2", + "cohort": "beta", + }, +}) +``` + ## Tool Spans ```go diff --git a/buffer.go b/buffer.go index 4605e7d..4e14b02 100644 --- a/buffer.go +++ b/buffer.go @@ -7,16 +7,17 @@ import ( ) type eventPatch struct { - EventName string - UserID string - ConvoID string - Input string - Output string - Model string - Properties map[string]any - Attachments []Attachment - IsPending *bool - Timestamp time.Time + EventName string + UserID string + ConvoID string + Input string + Output string + Model string + Properties map[string]any + Attachments []Attachment + FeatureFlags map[string]string + IsPending *bool + Timestamp time.Time } type stickyEventData struct { @@ -225,6 +226,15 @@ func mergeEventPatches(target, source eventPatch) eventPatch { if len(source.Attachments) > 0 { out.Attachments = append(cloneAttachments(target.Attachments), source.Attachments...) } + if target.FeatureFlags != nil || source.FeatureFlags != nil { + out.FeatureFlags = cloneStringMap(target.FeatureFlags) + if out.FeatureFlags == nil { + out.FeatureFlags = make(map[string]string, len(source.FeatureFlags)) + } + for key, value := range source.FeatureFlags { + out.FeatureFlags[key] = value + } + } return out } @@ -247,14 +257,15 @@ func mergeStickyEventData(existing stickyEventData, patch eventPatch) stickyEven } type trackPartialPayload struct { - EventID string `json:"event_id"` - UserID string `json:"user_id"` - Event string `json:"event"` - Timestamp string `json:"timestamp"` - AIData *aiDataPayload `json:"ai_data,omitempty"` - Properties map[string]any `json:"properties"` - Attachments []Attachment `json:"attachments"` - IsPending bool `json:"is_pending"` + EventID string `json:"event_id"` + UserID string `json:"user_id"` + Event string `json:"event"` + Timestamp string `json:"timestamp"` + AIData *aiDataPayload `json:"ai_data,omitempty"` + Properties map[string]any `json:"properties"` + Attachments []Attachment `json:"attachments"` + FeatureFlags map[string]string `json:"feature_flags,omitempty"` + IsPending bool `json:"is_pending"` } type aiDataPayload struct { @@ -319,6 +330,10 @@ func (c *Client) buildTrackPartialPayload(eventID string, patch eventPatch, stic IsPending: isPending, } + if len(patch.FeatureFlags) > 0 { + payload.FeatureFlags = cloneStringMap(patch.FeatureFlags) + } + if patch.Input != "" || patch.Output != "" || patch.Model != "" || convoID != "" { payload.AIData = &aiDataPayload{ Input: patch.Input, diff --git a/conformance/failures.txt b/conformance/failures.txt index 823ed74..a062a65 100644 --- a/conformance/failures.txt +++ b/conformance/failures.txt @@ -4,6 +4,7 @@ batching-double-flush-no-duplicate@fault: missing_feature # SDK never POSTs eve batching-flush-cycle-resets-queue@fault: missing_feature # SDK never POSTs events/track: the scenario's assertions target the ratified batched route, which the SDK lacks — behavior ships via single-object events/track_partial (DEV-1149) concurrency-batch-all-land@fault: missing_feature # requires the queued events/track complete-event buffer (batch drain/flush lifecycle) that the SDK lacks — ships single-object events/track_partial synchronously (DEV-1149; hot-path: DEV-1148) concurrency-retry-preserves-all@fault: missing_feature # requires the queued events/track complete-event buffer (batch drain/flush lifecycle) that the SDK lacks — ships single-object events/track_partial synchronously (DEV-1149; hot-path: DEV-1148) +feature-flags-request-shape@fault: missing_feature # SDK never POSTs events/track: track_ai carrying feature_flags ships the flags as a top-level feature_flags string→string object on a single-object events/track_partial body (wire shape matches dawn ingest + js-core), not the ratified events/track array batch (DEV-1149) hang-request-deadline@fault: missing_feature # SDK never POSTs events/track: the scenario's assertions target the ratified batched route, which the SDK lacks — behavior ships via single-object events/track_partial (DEV-1149) hot-path-sustained-track-ai@fault: bug # Class 1 violation (CONTRACT 1.1): sustained track_ai posts synchronously on the caller's path; also no events/track batch route (DEV-1148; route gap: DEV-1149) hot-path-track-nonblocking@fault: bug # Class 1 violation (CONTRACT 1.1): TrackEvent posts synchronously (network I/O + retry sleep on the caller's goroutine); also ships via events/track_partial, never events/track (DEV-1148; route gap: DEV-1149) diff --git a/conformance/main.go b/conformance/main.go index e5755b5..ee311f9 100644 --- a/conformance/main.go +++ b/conformance/main.go @@ -54,6 +54,12 @@ var ( "events.track_ai_partial", "events.track_partial", "identify", + // `feature_flags` maps to the public FeatureFlags map on the + // track/track_ai/begin/patch surfaces (DEV-1214): the SDK ships it as a + // top-level `feature_flags` string→string object on its + // events/track_partial body, matching dawn's ingest schema and the + // raindrop-js core event-shipper wire key. + "events.feature_flags", // `signal` maps to the public Client.TrackSignal surface (DEV-1201: // the capability went active once signal UUIDs populate on Query API // reads; signal scenarios are experimental until promoted). @@ -121,6 +127,30 @@ func stringArg(args map[string]any, key string) (string, error) { return s, nil } +// stringMapArg maps a harness object arg whose values are all strings onto a +// Go map[string]string (the shape of the SDK's public feature-flag surface). +// A non-string value is refused loudly rather than coerced (fleet +// non-negotiable: never silently drop or mangle a step arg). +func stringMapArg(args map[string]any, key string) (map[string]string, error) { + v, ok := args[key] + if !ok { + return nil, nil + } + m, ok := v.(map[string]any) + if !ok { + return nil, fmt.Errorf("arg %q: expected object, got %T", key, v) + } + out := make(map[string]string, len(m)) + for k, raw := range m { + s, ok := raw.(string) + if !ok { + return nil, fmt.Errorf("arg %q[%q]: expected string value, got %T", key, k, raw) + } + out[k] = s + } + return out, nil +} + func objectArg(args map[string]any, key string) (map[string]any, error) { v, ok := args[key] if !ok { @@ -299,12 +329,13 @@ func (d *driver) stepTrack(ctx context.Context, args map[string]any) error { return err } return client.TrackEvent(ctx, raindrop.Event{ - EventID: event.eventID, - UserID: event.userID, - Event: event.event, - Timestamp: event.timestamp, - Properties: event.properties, - Attachments: event.attachments, + EventID: event.eventID, + UserID: event.userID, + Event: event.event, + Timestamp: event.timestamp, + Properties: event.properties, + Attachments: event.attachments, + FeatureFlags: event.featureFlags, }) } @@ -318,16 +349,17 @@ func (d *driver) stepTrackAI(ctx context.Context, args map[string]any) error { return err } return client.TrackAI(ctx, raindrop.AIEvent{ - EventID: event.eventID, - UserID: event.userID, - Event: event.event, - Timestamp: event.timestamp, - Input: event.input, - Output: event.output, - Model: event.model, - ConvoID: event.convoID, - Properties: event.properties, - Attachments: event.attachments, + EventID: event.eventID, + UserID: event.userID, + Event: event.event, + Timestamp: event.timestamp, + Input: event.input, + Output: event.output, + Model: event.model, + ConvoID: event.convoID, + Properties: event.properties, + Attachments: event.attachments, + FeatureFlags: event.featureFlags, }) } @@ -426,15 +458,16 @@ func (d *driver) stepBegin(ctx context.Context, args map[string]any) error { return err } d.interaction = client.Begin(ctx, raindrop.BeginOptions{ - EventID: event.eventID, - UserID: event.userID, - Event: event.event, - Timestamp: event.timestamp, - Input: event.input, - Model: event.model, - ConvoID: event.convoID, - Properties: event.properties, - Attachments: event.attachments, + EventID: event.eventID, + UserID: event.userID, + Event: event.event, + Timestamp: event.timestamp, + Input: event.input, + Model: event.model, + ConvoID: event.convoID, + Properties: event.properties, + Attachments: event.attachments, + FeatureFlags: event.featureFlags, }) return nil } @@ -448,15 +481,16 @@ func (d *driver) stepPatch(args map[string]any) error { return err } return d.interaction.Patch(raindrop.PatchOptions{ - UserID: event.userID, - Event: event.event, - Timestamp: event.timestamp, - Input: event.input, - Output: event.output, - Model: event.model, - ConvoID: event.convoID, - Properties: event.properties, - Attachments: event.attachments, + UserID: event.userID, + Event: event.event, + Timestamp: event.timestamp, + Input: event.input, + Output: event.output, + Model: event.model, + ConvoID: event.convoID, + Properties: event.properties, + Attachments: event.attachments, + FeatureFlags: event.featureFlags, }) } @@ -469,11 +503,12 @@ func (d *driver) stepFinish(args map[string]any) error { return err } err = d.interaction.Finish(raindrop.FinishOptions{ - Timestamp: event.timestamp, - Output: event.output, - Model: event.model, - Properties: event.properties, - Attachments: event.attachments, + Timestamp: event.timestamp, + Output: event.output, + Model: event.model, + Properties: event.properties, + Attachments: event.attachments, + FeatureFlags: event.featureFlags, }) d.interaction = nil return err @@ -482,16 +517,17 @@ func (d *driver) stepFinish(args map[string]any) error { // eventArgs is the union of the harness's event-shaped step args; each step // handler forwards only the fields its SDK call accepts. type eventArgs struct { - eventID string - userID string - event string - input string - output string - model string - convoID string - timestamp time.Time - properties map[string]any - attachments []raindrop.Attachment + eventID string + userID string + event string + input string + output string + model string + convoID string + timestamp time.Time + properties map[string]any + attachments []raindrop.Attachment + featureFlags map[string]string } func eventFields(args map[string]any) (eventArgs, error) { @@ -519,6 +555,9 @@ func eventFields(args map[string]any) (eventArgs, error) { if out.attachments, err = attachmentsArg(args); err != nil { return out, err } + if out.featureFlags, err = stringMapArg(args, "feature_flags"); err != nil { + return out, err + } return out, nil } diff --git a/events.go b/events.go index 8c40190..22ae6f6 100644 --- a/events.go +++ b/events.go @@ -14,58 +14,63 @@ type Attachment struct { } type Event struct { - EventID string - UserID string - Event string - Timestamp time.Time - Properties map[string]any - Attachments []Attachment + EventID string + UserID string + Event string + Timestamp time.Time + Properties map[string]any + Attachments []Attachment + FeatureFlags map[string]string } type AIEvent struct { - EventID string - UserID string - Event string - Timestamp time.Time - Input string - Output string - Model string - ConvoID string - Properties map[string]any - Attachments []Attachment + EventID string + UserID string + Event string + Timestamp time.Time + Input string + Output string + Model string + ConvoID string + Properties map[string]any + Attachments []Attachment + FeatureFlags map[string]string } type BeginOptions struct { - EventID string - UserID string - Event string - Timestamp time.Time - Input string - Model string - ConvoID string - Properties map[string]any - Attachments []Attachment + EventID string + UserID string + Event string + Timestamp time.Time + Input string + Model string + ConvoID string + Properties map[string]any + Attachments []Attachment + FeatureFlags map[string]string } type PatchOptions struct { - UserID string - Event string - Timestamp time.Time - Input string - Output string - Model string - ConvoID string - Properties map[string]any - Attachments []Attachment - IsPending *bool + UserID string + Event string + Timestamp time.Time + Input string + Output string + Model string + ConvoID string + Properties map[string]any + Attachments []Attachment + FeatureFlags map[string]string + IsPending *bool } type FinishOptions struct { - Timestamp time.Time - Output string - Model string - Properties map[string]any - Attachments []Attachment + Timestamp time.Time + Output string + Model string + Properties map[string]any + Attachments []Attachment + FeatureFlags map[string]string } type Interaction struct { @@ -85,12 +90,13 @@ func (c *Client) TrackEvent(ctx context.Context, event Event) error { } done := false return c.Patch(ctx, eventID, PatchOptions{ - UserID: event.UserID, - Event: eventNameOrDefault(event.Event), - Timestamp: event.Timestamp, - Properties: cloneMap(event.Properties), - Attachments: cloneAttachments(event.Attachments), - IsPending: &done, + UserID: event.UserID, + Event: eventNameOrDefault(event.Event), + Timestamp: event.Timestamp, + Properties: cloneMap(event.Properties), + Attachments: cloneAttachments(event.Attachments), + FeatureFlags: cloneStringMap(event.FeatureFlags), + IsPending: &done, }) } @@ -105,16 +111,17 @@ func (c *Client) TrackAI(ctx context.Context, event AIEvent) error { } done := false return c.Patch(ctx, eventID, PatchOptions{ - UserID: event.UserID, - Event: eventNameOrDefault(event.Event), - Timestamp: event.Timestamp, - Input: event.Input, - Output: event.Output, - Model: event.Model, - ConvoID: event.ConvoID, - Properties: cloneMap(event.Properties), - Attachments: cloneAttachments(event.Attachments), - IsPending: &done, + UserID: event.UserID, + Event: eventNameOrDefault(event.Event), + Timestamp: event.Timestamp, + Input: event.Input, + Output: event.Output, + Model: event.Model, + ConvoID: event.ConvoID, + Properties: cloneMap(event.Properties), + Attachments: cloneAttachments(event.Attachments), + FeatureFlags: cloneStringMap(event.FeatureFlags), + IsPending: &done, }) } @@ -133,15 +140,16 @@ func (c *Client) Begin(ctx context.Context, opts BeginOptions) *Interaction { } pending := true _ = c.Patch(ctx, eventID, PatchOptions{ - UserID: opts.UserID, - Event: eventNameOrDefault(opts.Event), - Timestamp: opts.Timestamp, - Input: opts.Input, - Model: opts.Model, - ConvoID: opts.ConvoID, - Properties: cloneMap(opts.Properties), - Attachments: cloneAttachments(opts.Attachments), - IsPending: &pending, + UserID: opts.UserID, + Event: eventNameOrDefault(opts.Event), + Timestamp: opts.Timestamp, + Input: opts.Input, + Model: opts.Model, + ConvoID: opts.ConvoID, + Properties: cloneMap(opts.Properties), + Attachments: cloneAttachments(opts.Attachments), + FeatureFlags: cloneStringMap(opts.FeatureFlags), + IsPending: &pending, }) interaction := &Interaction{client: c, ctx: ctx, eventID: eventID} if eventID != "" { @@ -182,28 +190,30 @@ func (c *Client) Patch(ctx context.Context, eventID string, opts PatchOptions) e // at full size: the cost on the caller stays proportional to the cap. limit := c.textFieldLimit() return c.events.Patch(ctx, eventID, eventPatch{ - EventName: opts.Event, - UserID: opts.UserID, - Timestamp: opts.Timestamp, - Input: capText(opts.Input, limit), - Output: capText(opts.Output, limit), - Model: opts.Model, - ConvoID: opts.ConvoID, - Properties: capProperties(opts.Properties, limit), - Attachments: capAttachments(opts.Attachments, limit), - IsPending: opts.IsPending, + EventName: opts.Event, + UserID: opts.UserID, + Timestamp: opts.Timestamp, + Input: capText(opts.Input, limit), + Output: capText(opts.Output, limit), + Model: opts.Model, + ConvoID: opts.ConvoID, + Properties: capProperties(opts.Properties, limit), + Attachments: capAttachments(opts.Attachments, limit), + FeatureFlags: cloneStringMap(opts.FeatureFlags), + IsPending: opts.IsPending, }) } func (c *Client) Finish(ctx context.Context, eventID string, opts FinishOptions) error { done := false return c.Patch(ctx, eventID, PatchOptions{ - Timestamp: opts.Timestamp, - Output: opts.Output, - Model: opts.Model, - Properties: cloneMap(opts.Properties), - Attachments: cloneAttachments(opts.Attachments), - IsPending: &done, + Timestamp: opts.Timestamp, + Output: opts.Output, + Model: opts.Model, + Properties: cloneMap(opts.Properties), + Attachments: cloneAttachments(opts.Attachments), + FeatureFlags: cloneStringMap(opts.FeatureFlags), + IsPending: &done, }) } @@ -229,6 +239,10 @@ func (i *Interaction) SetProperties(properties map[string]any) error { return i.Patch(PatchOptions{Properties: cloneMap(properties)}) } +func (i *Interaction) SetFeatureFlags(featureFlags map[string]string) error { + return i.Patch(PatchOptions{FeatureFlags: cloneStringMap(featureFlags)}) +} + func (i *Interaction) SetProperty(key string, value any) error { if key == "" { return nil diff --git a/feature_flags_test.go b/feature_flags_test.go new file mode 100644 index 0000000..876507e --- /dev/null +++ b/feature_flags_test.go @@ -0,0 +1,178 @@ +package raindrop + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// TestTrackAISerializesFeatureFlags proves the public FeatureFlags surface +// serializes to a top-level `feature_flags` string→string object on the wire — +// the ratified shape (dawn ingest TrackEventSchema / raindrop-js core +// event-shipper). Multibyte values must round-trip to the wire unmangled. +func TestTrackAISerializesFeatureFlags(t *testing.T) { + var rawBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/events/track_partial" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + rawBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + client := newTestClient(t, server.URL+"/") + defer func() { _ = client.Close() }() + + err := client.TrackAI(context.Background(), AIEvent{ + EventID: "evt_flags", + UserID: "flags-u1", + Event: "chat", + Input: "How do I enable reasoning?", + Output: "Toggle it in Settings.", + Model: "mock-gpt", + FeatureFlags: map[string]string{ + "prompt-version": "v2", + "locale-label": "café-日本語", + }, + }) + if err != nil { + t.Fatalf("track ai: %v", err) + } + + // Decode into a shape-agnostic map so the assertion is on the actual wire + // JSON, not the SDK's own payload struct. + var wire map[string]json.RawMessage + if err := json.Unmarshal(rawBody, &wire); err != nil { + t.Fatalf("unmarshal wire body: %v", err) + } + rawFlags, ok := wire["feature_flags"] + if !ok { + t.Fatalf("feature_flags key absent from wire body: %s", rawBody) + } + var flags map[string]string + if err := json.Unmarshal(rawFlags, &flags); err != nil { + t.Fatalf("feature_flags is not a string→string object: %v (%s)", err, rawFlags) + } + if flags["prompt-version"] != "v2" { + t.Fatalf("prompt-version: got %q, want v2", flags["prompt-version"]) + } + if flags["locale-label"] != "café-日本語" { + t.Fatalf("locale-label: got %q, want café-日本語", flags["locale-label"]) + } +} + +// TestOmittedFeatureFlagsLeaveBodyUnchanged proves the additive guarantee: +// existing callers that never set FeatureFlags produce a wire body with NO +// `feature_flags` key — byte-identical to the pre-change payload (the field is +// json:",omitempty" and nil is never populated). +func TestOmittedFeatureFlagsLeaveBodyUnchanged(t *testing.T) { + var rawBody []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rawBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + client := newTestClient(t, server.URL+"/") + defer func() { _ = client.Close() }() + + if err := client.TrackAI(context.Background(), AIEvent{ + EventID: "evt_noflags", + UserID: "user-123", + Event: "ai_generation", + Input: "hello", + Output: "world", + Model: "gpt-4o", + }); err != nil { + t.Fatalf("track ai: %v", err) + } + + if strings.Contains(string(rawBody), "feature_flags") { + t.Fatalf("omitted flags leaked a feature_flags key onto the wire: %s", rawBody) + } + var wire map[string]json.RawMessage + if err := json.Unmarshal(rawBody, &wire); err != nil { + t.Fatalf("unmarshal wire body: %v", err) + } + if _, ok := wire["feature_flags"]; ok { + t.Fatalf("feature_flags key present despite omission: %s", rawBody) + } +} + +// TestInteractionFeatureFlagsMergeAcrossPartials proves flags set on the +// partial lifecycle (Begin/SetFeatureFlags) survive to the finalized flush and +// merge (later keys win) rather than replacing. +func TestInteractionFeatureFlagsMergeAcrossPartials(t *testing.T) { + var received trackPartialPayload + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + if err := json.Unmarshal(body, &received); err != nil { + t.Fatalf("unmarshal payload: %v", err) + } + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + client := newTestClient(t, server.URL+"/") + defer func() { _ = client.Close() }() + + interaction := client.Begin(context.Background(), BeginOptions{ + EventID: "evt_merge", + UserID: "user-123", + Input: "hi", + FeatureFlags: map[string]string{ + "prompt-version": "v1", + "cohort": "beta", + }, + }) + if err := interaction.SetFeatureFlags(map[string]string{"prompt-version": "v2"}); err != nil { + t.Fatalf("set feature flags: %v", err) + } + if err := interaction.Finish(FinishOptions{Output: "done"}); err != nil { + t.Fatalf("finish: %v", err) + } + + if received.FeatureFlags["prompt-version"] != "v2" { + t.Fatalf("later flag did not win: %#v", received.FeatureFlags) + } + if received.FeatureFlags["cohort"] != "beta" { + t.Fatalf("earlier flag not retained on merge: %#v", received.FeatureFlags) + } + if received.IsPending { + t.Fatalf("expected finalized event") + } +} + +// TestFeatureFlagsClonedFromCaller proves the SDK defensively copies the +// caller's map, so post-call mutation cannot alter buffered/serialized flags. +func TestFeatureFlagsClonedFromCaller(t *testing.T) { + var received trackPartialPayload + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &received) + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + client := newTestClient(t, server.URL+"/") + defer func() { _ = client.Close() }() + + flags := map[string]string{"prompt-version": "v2"} + if err := client.TrackAI(context.Background(), AIEvent{ + EventID: "evt_clone", + UserID: "user-123", + FeatureFlags: flags, + }); err != nil { + t.Fatalf("track ai: %v", err) + } + flags["prompt-version"] = "mutated" + + if received.FeatureFlags["prompt-version"] != "v2" { + t.Fatalf("caller mutation leaked into shipped flags: %#v", received.FeatureFlags) + } +} diff --git a/helpers.go b/helpers.go index d0daa0a..dfec10c 100644 --- a/helpers.go +++ b/helpers.go @@ -34,6 +34,17 @@ func mergeMaps(base map[string]any, overlay map[string]any) map[string]any { return merged } +func cloneStringMap(src map[string]string) map[string]string { + if src == nil { + return nil + } + dst := make(map[string]string, len(src)) + for key, value := range src { + dst[key] = value + } + return dst +} + func cloneAttachments(src []Attachment) []Attachment { if len(src) == 0 { return nil diff --git a/version.go b/version.go index 55c2889..b6f7bc7 100644 --- a/version.go +++ b/version.go @@ -1,3 +1,3 @@ package raindrop -const Version = "0.1.5" +const Version = "0.1.6"