Skip to content
Merged
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
42 changes: 42 additions & 0 deletions claimrelay/claimrelay.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package claimrelay

import (
"context"
"encoding/json"

"github.com/TicketsBot-cloud/common/utils"
"github.com/go-redis/redis/v8"
)

type TicketClaim struct {
GuildId uint64 `json:"guild_id"`
TicketId int `json:"ticket_id"`
UserId uint64 `json:"user_id"` // who performed the action
Claim bool `json:"claim"` // true = claim, false = unclaim
}

const key = "tickets:claim"

func Publish(redis *redis.Client, data TicketClaim) error {
marshalled, err := json.Marshal(data)
if err != nil {
return err
}
return redis.RPush(utils.DefaultContext(), key, string(marshalled)).Err()
}

func Listen(redis *redis.Client, ch chan TicketClaim) {
for {
res, err := redis.BLPop(context.Background(), 0, key).Result()
if err != nil {
continue
}

var data TicketClaim
if err := json.Unmarshal([]byte(res[1]), &data); err != nil {
continue
}

ch <- data
}
}
5 changes: 5 additions & 0 deletions closerelay/closerelay.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ type TicketClose struct {
TicketId int `json:"ticket_id"`
UserId uint64 `json:"user_id"`
Reason string `json:"reason"`
// CausationId propagates a workflow-run causation when the close is triggered
// by an automation. Empty for user/dashboard-initiated closes. The worker's
// close listener stamps this onto the synthetic worker.Context so downstream
// trigger emissions carry it forward, letting the recursion guard catch loops.
CausationId string `json:"causation_id,omitempty"`
}

const key = "tickets:close"
Expand Down
4 changes: 1 addition & 3 deletions experiments/experiments.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,10 @@ import (
type Experiment string

const (
COMPONENTS_V2_STATISTICS Experiment = "COMPONENTS_V2_STATISTICS"
API_BASED_FORM_INPUTS Experiment = "API_BASED_FORM_INPUTS"
API_BASED_FORM_INPUTS Experiment = "API_BASED_FORM_INPUTS"
)

var List = []Experiment{
COMPONENTS_V2_STATISTICS,
API_BASED_FORM_INPUTS,
}

Expand Down
180 changes: 180 additions & 0 deletions featureflags/attributes.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
package featureflags

import (
"strconv"

gb "github.com/growthbook/growthbook-golang"
)

// Identifier attribute names. These are the values a GrowthBook experiment's
// "assignment attribute" must be set to, and they double as the identifier_type
// recorded against an exposure.
const (
AttrGuild = "guild_id"
AttrUser = "user_id"
AttrDashboardUser = "dashboard_user_id"
)

// Targeting attributes that are not assignment units. Rules match on these but
// never bucket on them.
const (
AttrPremiumTier = "premium_tier"
AttrEntitlementSource = "entitlement_source"
AttrShard = "shard"
AttrGuildSize = "guild_size"
AttrStaffTier = "staff_tier"
)

// Attributes describes the entity a flag is being evaluated for. Build one with
// ForGuild, ForUser or ForDashboardUser so the bucketing unit is always explicit
// rather than inferred, then add optional targeting data with the With helpers.
//
// The old implementation bucketed on guildId % 100 with no per-flag salt, which
// meant every experiment enrolled the same guilds. GrowthBook hashes the primary
// identifier together with a per-flag seed, so cohorts are independent by
// construction. That only holds if the primary identifier is set, hence the
// constructors.
type Attributes struct {
// primary is the attribute name GrowthBook buckets on by default.
primary string

guildId uint64
userId uint64
dashboardUserId uint64

premiumTier *int8
entitlementSource string
shard *int
guildSize *int
staffTier string

// extra carries attributes that do not warrant a field here. It exists so a
// new targeting dimension does not require a tagged release of common and a
// version bump in every consuming service.
extra map[string]any
}

// ForGuild buckets on the guild, which is the right unit for most bot features.
func ForGuild(guildId uint64) Attributes {
return Attributes{primary: AttrGuild, guildId: guildId}
}

// ForUser buckets on the Discord user. The guild is still recorded so rules can
// target both, but assignment follows the user across guilds.
func ForUser(guildId, userId uint64) Attributes {
return Attributes{primary: AttrUser, guildId: guildId, userId: userId}
}

// ForDashboardUser buckets on the logged-in web user, for dashboard-only rollouts.
func ForDashboardUser(userId uint64) Attributes {
return Attributes{primary: AttrDashboardUser, dashboardUserId: userId}
}

func (a Attributes) WithPremiumTier(tier int8) Attributes {
a.premiumTier = &tier
return a
}

func (a Attributes) WithEntitlementSource(source string) Attributes {
a.entitlementSource = source
return a
}

func (a Attributes) WithShard(shard int) Attributes {
a.shard = &shard
return a
}

func (a Attributes) WithGuildSize(size int) Attributes {
a.guildSize = &size
return a
}

// WithGuild attaches a guild ID as a targeting attribute without changing the
// bucketing unit. Use this when the primary unit is something else (e.g. a
// dashboard user) but guild-targeted rules ("Specific servers", "Percentage of
// servers" bucketed on guild) still need to match.
func (a Attributes) WithGuild(guildId uint64) Attributes {
a.guildId = guildId
return a
}

// WithStaffTier marks the evaluation as being for a member of bot staff, so flags
// can be dogfooded internally before any customer sees them. Expects one of
// "helper", "admin" or "owner"; an empty string is treated as not staff.
//
// Callers must supply this for staff-targeted rules to match. A rule targeting
// staff at a call site that never sets it will silently never fire.
func (a Attributes) WithStaffTier(tier string) Attributes {
a.staffTier = tier
return a
}

// WithExtra attaches an arbitrary targeting attribute. Prefer a typed helper for
// anything used more than once.
func (a Attributes) WithExtra(key string, value any) Attributes {
// Copy on write: Attributes is passed by value, so mutating a shared map
// would leak between callers that derived from the same base.
next := make(map[string]any, len(a.extra)+1)
for k, v := range a.extra {
next[k] = v
}
next[key] = value
a.extra = next
return a
}

// toGrowthBook converts to the SDK's attribute map.
//
// Snowflake IDs are always emitted as strings. They exceed the 2^53 integer
// range a JSON number can represent exactly, so passing them as numbers risks
// silent precision loss, which would move a guild between buckets.
func (a Attributes) toGrowthBook() gb.Attributes {
attrs := make(gb.Attributes, 8+len(a.extra))

for k, v := range a.extra {
attrs[k] = v
}

if a.guildId != 0 {
attrs[AttrGuild] = strconv.FormatUint(a.guildId, 10)
}

if a.userId != 0 {
attrs[AttrUser] = strconv.FormatUint(a.userId, 10)
}

if a.dashboardUserId != 0 {
attrs[AttrDashboardUser] = strconv.FormatUint(a.dashboardUserId, 10)
}

if a.premiumTier != nil {
attrs[AttrPremiumTier] = int(*a.premiumTier)
}

if a.entitlementSource != "" {
attrs[AttrEntitlementSource] = a.entitlementSource
}

if a.shard != nil {
attrs[AttrShard] = *a.shard
}

if a.guildSize != nil {
attrs[AttrGuildSize] = *a.guildSize
}

if a.staffTier != "" {
attrs[AttrStaffTier] = a.staffTier
}

// GrowthBook falls back to the "id" attribute when a feature does not name an
// assignment attribute of its own, so mirror the chosen primary onto it. This
// makes a flag created in the UI with default settings bucket on the unit the
// caller intended instead of silently not bucketing at all.
if primary, ok := attrs[a.primary]; ok {
attrs["id"] = primary
}

return attrs
}
43 changes: 43 additions & 0 deletions featureflags/cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package featureflags

import (
"context"
"errors"
"fmt"

"github.com/go-redis/redis/v8"
)

// payloadKey holds the last-known-good GrowthBook payload.
//
// Deliberately has no TTL. A stale ruleset is far better than none: if
// GrowthBook has been down for longer than any TTL we would have picked, a
// restarting worker should still come up with the rules it had before rather
// than with every flag off.
const payloadKey = "featureflags:payload"

type payloadCache struct {
redis *redis.Client
}

// get returns the cached payload, or an empty string if nothing is cached.
func (p *payloadCache) get(ctx context.Context) (string, error) {
payload, err := p.redis.Get(ctx, payloadKey).Result()
if err != nil {
if errors.Is(err, redis.Nil) {
return "", nil
}

return "", fmt.Errorf("featureflags: reading %s: %w", payloadKey, err)
}

return payload, nil
}

func (p *payloadCache) set(ctx context.Context, payload string) error {
if err := p.redis.Set(ctx, payloadKey, payload, 0).Err(); err != nil {
return fmt.Errorf("featureflags: writing %s: %w", payloadKey, err)
}

return nil
}
98 changes: 98 additions & 0 deletions featureflags/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package featureflags

import (
"fmt"
"time"
)

// Config describes how to reach GrowthBook. When both ApiHost and ClientKey are
// empty the whole subsystem degrades to compiled defaults rather than failing,
// so self-hosted deployments and local development work with no configuration.
// Setting only one of the two is rejected by validate as a broken config,
// rather than being treated the same as neither being set.
type Config struct {
// ApiHost is the GrowthBook backend as reached from inside the cluster, so
// http://growthbook-backend.growthbook.svc:3100 rather than the Twingate
// alias. The alias exists for staff browsers; pointing services at it would
// send in-cluster traffic out through the tunnel and back.
ApiHost string `env:"GROWTHBOOK_API_HOST"`

// ClientKey is an SDK connection key from GrowthBook, not an API key. It only
// grants read access to the flag payload for one environment.
ClientKey string `env:"GROWTHBOOK_CLIENT_KEY"`

// DecryptionKey is only needed if the SDK connection in GrowthBook has
// encrypted payloads enabled.
DecryptionKey string `env:"GROWTHBOOK_DECRYPTION_KEY"`

// UseSSE opts into streaming flag updates instead of polling.
//
// Defaults to false because a stock self-hosted GrowthBook does not serve
// streams: the payload response carries no x-sse-support header and
// /sub/<clientKey> returns 401. Streaming is a GrowthBook Proxy feature, so
// only enable this once the proxy is deployed, otherwise startup fails to
// load any rules and every flag evaluates to off.
UseSSE bool `env:"GROWTHBOOK_USE_SSE" envDefault:"false"`

// PollInterval is used when UseSSE is false. The SDK sends a conditional
// request and gets a 304 when nothing changed, so a short interval is cheap.
// This is also the upper bound on how long a kill switch takes to reach a
// running process.
PollInterval time.Duration `env:"GROWTHBOOK_POLL_INTERVAL" envDefault:"30s"`

// LoadTimeout bounds how long startup waits for the first payload before
// falling back to the cached copy in Redis. Keep it short: a process must
// never block on GrowthBook being reachable.
LoadTimeout time.Duration `env:"GROWTHBOOK_LOAD_TIMEOUT" envDefault:"5s"`

// CacheRefreshInterval is how often the last-known-good payload is written
// to Redis so restarting processes have something to boot from.
CacheRefreshInterval time.Duration `env:"GROWTHBOOK_CACHE_REFRESH_INTERVAL" envDefault:"5m"`
}

// Enabled reports whether enough configuration is present to talk to GrowthBook.
func (c Config) Enabled() bool {
return c.ApiHost != "" && c.ClientKey != ""
}

// Attempted reports whether any GrowthBook configuration was supplied at all,
// as opposed to neither ApiHost nor ClientKey being set.
//
// This is deliberately looser than Enabled: it is true for a valid config and
// also for a broken, partially-set one (only one of the two present). Callers
// deciding whether to fail closed on a construction error must use this, not
// Enabled, otherwise a config missing just one of the two env vars (a routine
// secret-rotation or ConfigMap mistake) is indistinguishable from "GrowthBook
// was never configured" and the caller fails open instead of closed. validate
// rejects a partial config outright, so in practice Attempted only needs to
// cover the error path from New; a config that reaches Enabled() has already
// passed validate and cannot be partial.
func (c Config) Attempted() bool {
return c.ApiHost != "" || c.ClientKey != ""
}

func (c Config) validate() error {
// Half-configured is worse than unconfigured: unconfigured deliberately
// defaults every flag to enabled (see the package doc comment), but a config
// missing just one of ApiHost/ClientKey would otherwise fall into that same
// branch by accident, silently turning every kill switch on. Reject it
// outright instead of letting Enabled() treat "one set" and "none set" the
// same way.
if (c.ApiHost == "") != (c.ClientKey == "") {
return fmt.Errorf("featureflags: partial GrowthBook configuration (ApiHost set=%v, ClientKey set=%v): both must be set, or neither", c.ApiHost != "", c.ClientKey != "")
}

if c.LoadTimeout <= 0 {
return fmt.Errorf("featureflags: LoadTimeout must be positive, got %s", c.LoadTimeout)
}

if !c.UseSSE && c.PollInterval <= 0 {
return fmt.Errorf("featureflags: PollInterval must be positive when SSE is disabled, got %s", c.PollInterval)
}

if c.CacheRefreshInterval <= 0 {
return fmt.Errorf("featureflags: CacheRefreshInterval must be positive, got %s", c.CacheRefreshInterval)
}

return nil
}
Loading
Loading