Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/conformance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/conformance/driver
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 33 additions & 18 deletions buffer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}

Expand All @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions conformance/failures.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
137 changes: 88 additions & 49 deletions conformance/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
})
}

Expand All @@ -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,
})
}

Expand Down Expand Up @@ -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
}
Expand All @@ -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,
})
}

Expand All @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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
}

Expand Down
Loading
Loading