├── .travis.yml ├── defaults.go ├── errors.go ├── scripts.go ├── utils_test.go ├── utils.go ├── LICENSE.md ├── keys.go ├── bitesized.go ├── event.go ├── user.go ├── retention.go ├── interval.go ├── bitesized_test.go ├── user_test.go ├── interval_test.go ├── retention_test.go ├── Readme.md └── event_test.go /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | services: 4 | - redis-server 5 | 6 | go: 7 | - 1.4 8 | -------------------------------------------------------------------------------- /defaults.go: -------------------------------------------------------------------------------- 1 | package bitesized 2 | 3 | // Defines defaults that are used by library. These values can be ovewrritten 4 | // by the user. 5 | var ( 6 | DefaultIntervals = []Interval{All, Day, Week, Month} 7 | DefaultKeyPrefix = "bitesized" 8 | ) 9 | -------------------------------------------------------------------------------- /errors.go: -------------------------------------------------------------------------------- 1 | package bitesized 2 | 3 | import "errors" 4 | 5 | // Defines errors that are used by library. 6 | var ( 7 | ErrInvalidArg = errors.New("invalid argument(s)") 8 | ErrFromAfterTill = errors.New("from date after till") 9 | ErrNotOpAcceptsOnekey = errors.New("NOT op only accepts one key") 10 | ) 11 | -------------------------------------------------------------------------------- /scripts.go: -------------------------------------------------------------------------------- 1 | package bitesized 2 | 3 | // Defines lua scripts that are used by library. 4 | var getOrSetUserScript = ` 5 | if redis.call('HEXISTS', KEYS[1], KEYS[2]) == 1 then 6 | return redis.call('HGET', KEYS[1], KEYS[2]) 7 | else 8 | local id = redis.call('INCR', KEYS[3]) 9 | redis.call('HSET', KEYS[1], KEYS[2], id) 10 | redis.call('HSET', KEYS[4], id, KEYS[2]) 11 | 12 | return id 13 | end` 14 | -------------------------------------------------------------------------------- /utils_test.go: -------------------------------------------------------------------------------- 1 | package bitesized 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/smartystreets/goconvey/convey" 7 | ) 8 | 9 | func TestDasherize(t *testing.T) { 10 | Convey("It should split event on space and join with dash", t, func() { 11 | So(dasherize("dodge"), ShouldEqual, "dodge") 12 | So(dasherize("dodge rock"), ShouldEqual, "dodge-rock") 13 | }) 14 | } 15 | 16 | func TestRandomSeq(t *testing.T) { 17 | Convey("It should return random string", t, func() { 18 | So(randSeq(20), ShouldNotEqual, randSeq(2)) 19 | }) 20 | } 21 | -------------------------------------------------------------------------------- /utils.go: -------------------------------------------------------------------------------- 1 | package bitesized 2 | 3 | import ( 4 | "math/rand" 5 | "strings" 6 | "time" 7 | ) 8 | 9 | func init() { 10 | rand.Seed(time.Now().Unix()) 11 | } 12 | 13 | // Taken from: http://stackoverflow.com/questions/22892120/how-to-generate-a-random-string-of-a-fixed-length-in-golang 14 | var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") 15 | 16 | func randSeq(n int) string { 17 | b := make([]rune, n) 18 | for i := range b { 19 | b[i] = letters[rand.Intn(len(letters))] 20 | } 21 | 22 | return string(b) 23 | } 24 | 25 | func dasherize(evnt string) string { 26 | return strings.Join(strings.Split(evnt, " "), "-") 27 | } 28 | 29 | // Taken from: http://stackoverflow.com/questions/30272881/unpack-redis-set-bit-string-in-go 30 | func bitStringToBools(str string) []bool { 31 | bools := make([]bool, 0, len(str)*8) 32 | 33 | for i := 0; i < len(str); i++ { 34 | for bit := 7; bit >= 0; bit-- { 35 | isSet := (str[i]>>uint(bit))&1 == 1 36 | bools = append(bools, isSet) 37 | } 38 | } 39 | 40 | return bools 41 | } 42 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Senthil Arivudainambi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /keys.go: -------------------------------------------------------------------------------- 1 | package bitesized 2 | 3 | import ( 4 | "strings" 5 | "time" 6 | ) 7 | 8 | type Op string 9 | 10 | var ( 11 | On = 1 12 | Off = 0 13 | 14 | AND Op = "AND" 15 | OR Op = "OR" 16 | XOR Op = "XOR" 17 | NOT Op = "NOT" 18 | ) 19 | 20 | // Defines keys that are used by library. 21 | var ( 22 | EventRegex = "event:(.*?):" 23 | EventPrefixKey = "event" 24 | UserListKey = "user-list" 25 | UserCounterKey = "user-counter" 26 | UserIdListKey = "user-id-list" 27 | ) 28 | 29 | func (b *Bitesized) intervalkey(evnt string, t time.Time, i Interval) string { 30 | intervalkey := nearestInterval(t, i) 31 | return b.key(EventPrefixKey, evnt, intervalkey) 32 | } 33 | 34 | func (b *Bitesized) userIdListKey() string { 35 | return b.key(UserIdListKey) 36 | } 37 | 38 | func (b *Bitesized) userListKey() string { 39 | return b.key(UserListKey) 40 | } 41 | 42 | func (b *Bitesized) userCounterKey() string { 43 | return b.key(UserCounterKey) 44 | } 45 | 46 | func (b *Bitesized) allEventsKey() string { 47 | return b.key(EventPrefixKey) + ":*" 48 | } 49 | 50 | func (b *Bitesized) key(suffix ...string) string { 51 | dasherized := []string{} 52 | for _, s := range suffix { 53 | dasherized = append(dasherized, dasherize(s)) 54 | } 55 | 56 | key := strings.Join(dasherized, ":") 57 | 58 | if b.KeyPrefix != "" { 59 | key = b.KeyPrefix + ":" + key 60 | } 61 | 62 | return key 63 | } 64 | -------------------------------------------------------------------------------- /bitesized.go: -------------------------------------------------------------------------------- 1 | package bitesized 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/garyburd/redigo/redis" 7 | ) 8 | 9 | // Bitesized is a client that can be used to track events and retrieve metrics. 10 | type Bitesized struct { 11 | store redis.Conn 12 | 13 | // Intervals stores list of intervals that are tracked. 14 | Intervals []Interval 15 | 16 | // KeyPrefix is the prefix that'll be appended to all keys. 17 | KeyPrefix string 18 | } 19 | 20 | // NewClient initializes a Bitesized client with redis conn & default values. 21 | func NewClient(redisuri string) (*Bitesized, error) { 22 | redissession, err := redis.Dial("tcp", redisuri) 23 | if err != nil { 24 | return nil, err 25 | } 26 | 27 | client := &Bitesized{ 28 | store: redissession, 29 | Intervals: DefaultIntervals, 30 | KeyPrefix: DefaultKeyPrefix, 31 | } 32 | 33 | return client, nil 34 | } 35 | 36 | func (b *Bitesized) Operation(op Op, keys ...string) (float64, error) { 37 | if op == NOT && len(keys) != 1 { 38 | return 0, ErrNotOpAcceptsOnekey 39 | } 40 | 41 | rKey := randSeq(20) 42 | 43 | args := []interface{}{op, rKey} 44 | for _, key := range keys { 45 | args = append(args, key) 46 | } 47 | 48 | if _, err := b.store.Do("BITOP", args...); err != nil { 49 | return 0, err 50 | } 51 | 52 | count, err := redis.Int(b.store.Do("BITCOUNT", rKey)) 53 | if err != nil { 54 | return 0, err 55 | } 56 | 57 | if _, err := b.store.Do("DEL", rKey); err != nil { 58 | return 0, err 59 | } 60 | 61 | return float64(count), nil 62 | } 63 | 64 | func (b *Bitesized) changeBit(e, u string, t time.Time, s int) error { 65 | if e == "" || u == "" { 66 | return ErrInvalidArg 67 | } 68 | 69 | offset, err := b.getOrSetUser(u) 70 | if err != nil { 71 | return err 72 | } 73 | 74 | b.store.Send("MULTI") 75 | 76 | for _, interval := range b.Intervals { 77 | key := b.intervalkey(e, t, interval) 78 | b.store.Send("SETBIT", key, offset, s) 79 | } 80 | 81 | _, err = b.store.Do("EXEC") 82 | 83 | return err 84 | } 85 | -------------------------------------------------------------------------------- /event.go: -------------------------------------------------------------------------------- 1 | package bitesized 2 | 3 | import ( 4 | "regexp" 5 | "time" 6 | 7 | "github.com/garyburd/redigo/redis" 8 | ) 9 | 10 | // TrackEvent tracks event for an user. It tracks only for specified intervals. 11 | func (b *Bitesized) TrackEvent(evnt, user string, tstamp time.Time) error { 12 | return b.changeBit(evnt, user, tstamp, On) 13 | } 14 | 15 | // UntrackEvent untracks event for an user. It tracks only for specified intervals. 16 | func (b *Bitesized) UntrackEvent(evnt, user string, tstamp time.Time) error { 17 | return b.changeBit(evnt, user, tstamp, Off) 18 | } 19 | 20 | // CountEvent returns count of users who did given event for given interval and time. 21 | func (b *Bitesized) CountEvent(e string, t time.Time, i Interval) (int, error) { 22 | key := b.intervalkey(e, t, i) 23 | return redis.Int(b.store.Do("BITCOUNT", key)) 24 | } 25 | 26 | // DidEvent returns if an user did a given event for given interval and time. 27 | func (b *Bitesized) DidEvent(e, u string, t time.Time, i Interval) (bool, error) { 28 | key := b.intervalkey(e, t, i) 29 | 30 | offset, err := b.getOrSetUser(u) 31 | if err != nil { 32 | return false, err 33 | } 34 | 35 | return redis.Bool(b.store.Do("GETBIT", key, offset)) 36 | } 37 | 38 | // GetEvents returns list of events tracked by the library. It uses a regex which is less than ideal. 39 | func (b *Bitesized) GetEvents(prefix string) ([]string, error) { 40 | prefix = b.key(EventPrefixKey, prefix) 41 | allkeys, err := redis.Strings(b.store.Do("KEYS", prefix)) 42 | if err != nil { 43 | return nil, err 44 | } 45 | 46 | rr := map[string]bool{} 47 | keys := []string{} 48 | 49 | for _, key := range allkeys { 50 | r := regexp.MustCompile(EventRegex) 51 | results := r.FindAllStringSubmatch(key, -1) 52 | 53 | if len(results) == 0 || len(results[0]) == 0 { 54 | continue 55 | } 56 | 57 | evnt := results[0][1] 58 | if _, ok := rr[evnt]; !ok { 59 | rr[evnt] = true 60 | keys = append(keys, key) 61 | } 62 | } 63 | 64 | return keys, nil 65 | } 66 | -------------------------------------------------------------------------------- /user.go: -------------------------------------------------------------------------------- 1 | package bitesized 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/garyburd/redigo/redis" 7 | ) 8 | 9 | // IsUserNew returns if an user has been seen by the library or not. 10 | func (b *Bitesized) IsUserNew(user string) (bool, error) { 11 | userExists, err := redis.Bool(b.store.Do("HEXISTS", b.userListKey(), user)) 12 | return !userExists, err 13 | } 14 | 15 | // EventUsers returns list of users who did a given event for given interval and time. 16 | func (b *Bitesized) EventUsers(e string, t time.Time, i Interval) ([]string, error) { 17 | key := b.intervalkey(e, t, i) 18 | str, err := redis.String(b.store.Do("GET", key)) 19 | if err != nil { 20 | return []string{}, err 21 | } 22 | 23 | idTobools := bitStringToBools(str) 24 | 25 | key = b.userIdListKey() 26 | args := []interface{}{key} 27 | 28 | for userIndex, userDidEvent := range idTobools { 29 | if userDidEvent { 30 | args = append(args, userIndex) 31 | } 32 | } 33 | 34 | return redis.Strings(b.store.Do("HMGET", args...)) 35 | } 36 | 37 | // RemoveUser unsets all events did by an user. 38 | func (b *Bitesized) RemoveUser(user string) error { 39 | eventkeys, err := redis.Strings(b.store.Do("KEYS", b.allEventsKey())) 40 | if err != nil { 41 | return err 42 | } 43 | 44 | offset, err := b.getOrSetUser(user) 45 | if err != nil { 46 | return err 47 | } 48 | 49 | b.store.Send("MULTI") 50 | 51 | for _, event := range eventkeys { 52 | b.store.Send("SETBIT", event, offset, Off) 53 | } 54 | 55 | _, err = b.store.Do("EXEC") 56 | 57 | return err 58 | } 59 | 60 | func (b *Bitesized) getOrSetUser(user string) (int, error) { 61 | user = dasherize(user) 62 | 63 | script := redis.NewScript(4, getOrSetUserScript) 64 | raw, err := script.Do( 65 | b.store, b.userListKey(), user, b.userCounterKey(), b.userIdListKey(), 66 | ) 67 | 68 | return redis.Int(raw, err) 69 | } 70 | 71 | func (b *Bitesized) getUserById(id int) (string, error) { 72 | key := b.key(UserIdListKey) 73 | return redis.String(b.store.Do("HGET", key, id)) 74 | } 75 | -------------------------------------------------------------------------------- /retention.go: -------------------------------------------------------------------------------- 1 | package bitesized 2 | 3 | import "time" 4 | 5 | type Retention map[string][]float64 6 | 7 | // Retention returns retention for a particular event and particular interval. 8 | // Each retention metric is cumulative of all previous intervals. 9 | func (b *Bitesized) Retention(e string, f, t time.Time, i Interval, ct int) ([]Retention, error) { 10 | if f.After(t) { 11 | return nil, ErrFromAfterTill 12 | } 13 | 14 | retentions := []Retention{} 15 | 16 | start := f 17 | for { 18 | end := start 19 | keyAggr := []string{} 20 | counts := []float64{} 21 | 22 | for { 23 | keyAggr = append(keyAggr, b.intervalkey(e, end, i)) 24 | 25 | c, err := b.Operation(AND, keyAggr...) 26 | if err != nil { 27 | return nil, err 28 | } 29 | 30 | counts = append(counts, c) 31 | if len(counts) == ct { 32 | break 33 | } 34 | 35 | if end = end.Add(getDuration(end, i)); end.After(t) { 36 | break 37 | } 38 | } 39 | 40 | r := Retention{nearestInterval(start, i): counts} 41 | retentions = append(retentions, r) 42 | 43 | if start = start.Add(getDuration(start, i)); start.After(t) { 44 | break 45 | } 46 | } 47 | 48 | return retentions, nil 49 | } 50 | 51 | // RetentionPercent returns retention percentage for a particular event and 52 | // particular interval. It's different from Retention in that it returns 53 | // percentages instead of actual numbers. 54 | func (b *Bitesized) RetentionPercent(e string, f, t time.Time, i Interval, ct int) ([]Retention, error) { 55 | retentions, err := b.Retention(e, f, t, i, ct) 56 | if err != nil { 57 | return nil, err 58 | } 59 | 60 | for _, rets := range retentions { 61 | for timekey, values := range rets { 62 | first := values[0] 63 | percents := []float64{first} 64 | 65 | for _, r := range values[1:] { 66 | var value float64 67 | if first != 0 { 68 | value = r / first 69 | } 70 | 71 | value = (float64(int(value*100)) / 100) 72 | percents = append(percents, value) 73 | } 74 | 75 | rets[timekey] = percents 76 | } 77 | } 78 | 79 | return retentions, nil 80 | } 81 | -------------------------------------------------------------------------------- /interval.go: -------------------------------------------------------------------------------- 1 | package bitesized 2 | 3 | import ( 4 | "math" 5 | "time" 6 | 7 | "github.com/jinzhu/now" 8 | ) 9 | 10 | // Interval define which time intervals to track events. Ex: `Month` interval 11 | // turns on bit for that user in the specified month's bit array. Multiple 12 | // intervals can be selected. 13 | type Interval int 14 | 15 | const ( 16 | All Interval = iota 17 | TenMinutes 18 | ThirtyMinutes 19 | Hour 20 | Day 21 | Biweekly 22 | Week 23 | Bimonthly 24 | Month 25 | Quarter 26 | Year 27 | ) 28 | 29 | func handleMinuteInterval(t time.Time, n *now.Now, cycleLength int, keyName string) string { 30 | layout := keyName + ":2006-01-02-15:04" 31 | offset := t.Sub(n.BeginningOfHour()) 32 | cycle := int(math.Floor(offset.Minutes() / float64(cycleLength))) 33 | return n.BeginningOfHour().Add(time.Duration(cycle*cycleLength) * time.Minute).Format(layout) 34 | } 35 | 36 | func nearestInterval(t time.Time, interval Interval) string { 37 | n := now.New(t.UTC()) 38 | 39 | switch interval { 40 | case All: 41 | return "all" 42 | case TenMinutes: 43 | return handleMinuteInterval(t, n, 10, "ten_minutes") 44 | case ThirtyMinutes: 45 | return handleMinuteInterval(t, n, 30, "thirty_minutes") 46 | case Day: 47 | layout := "day:2006-01-02" 48 | return n.BeginningOfDay().Format(layout) 49 | case Biweekly: 50 | layout := "biweekly:2006-01-02" 51 | date := n.BeginningOfWeek() 52 | if offset := t.Sub(n.BeginningOfWeek()); offset.Hours() > 84 { 53 | date = date.Add(84 * time.Hour) 54 | } 55 | return date.Format(layout) 56 | case Week: 57 | layout := "week:2006-01-02" 58 | return n.BeginningOfWeek().Format(layout) 59 | case Bimonthly: 60 | layout := "bimonthly:2006-01-02" 61 | monthMiddle := n.EndOfMonth().Sub(n.BeginningOfMonth()) / 2 62 | date := n.BeginningOfMonth() 63 | if offset := t.Sub(n.BeginningOfMonth()); offset > monthMiddle { 64 | date = date.Add(monthMiddle) 65 | } 66 | return date.Format(layout) 67 | case Month: 68 | layout := "month:2006-01" 69 | return n.BeginningOfMonth().Format(layout) 70 | case Quarter: 71 | layout := "quarter:2006-01" 72 | return n.BeginningOfQuarter().Format(layout) 73 | case Year: 74 | layout := "year:2006" 75 | return n.BeginningOfYear().Format(layout) 76 | } 77 | 78 | layout := "hour:2006-01-02-15:04" 79 | return n.BeginningOfHour().Format(layout) 80 | } 81 | 82 | func getDuration(t time.Time, i Interval) time.Duration { 83 | switch i { 84 | case Day: 85 | return 24 * time.Hour 86 | case Week: 87 | return 7 * 24 * time.Hour 88 | case Month: 89 | noOfDays := daysIn(t.Month(), t.Year()) 90 | return time.Duration(noOfDays) * 24 * time.Hour 91 | case Year: 92 | return 365 * 24 * time.Hour 93 | } 94 | 95 | return time.Hour 96 | } 97 | 98 | func daysIn(m time.Month, year int) int { 99 | return time.Date(year, m+1, 0, 0, 0, 0, 0, time.UTC).Day() 100 | } 101 | -------------------------------------------------------------------------------- /bitesized_test.go: -------------------------------------------------------------------------------- 1 | package bitesized 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/smartystreets/goconvey/convey" 7 | ) 8 | 9 | var ( 10 | testredis = "localhost:6379" 11 | user = "indianajones" 12 | ) 13 | 14 | func TestNewClient(t *testing.T) { 15 | Convey("It should initialize client", t, func() { 16 | client, err := NewClient(testredis) 17 | So(err, ShouldBeNil) 18 | 19 | Convey("With redis connection", func() { 20 | So(client.store, ShouldNotBeNil) 21 | }) 22 | 23 | Convey("With default values", func() { 24 | So(len(client.Intervals), ShouldBeGreaterThan, 1) 25 | So(client.KeyPrefix, ShouldEqual, "bitesized") 26 | }) 27 | 28 | Reset(func() { client.store.Do("FLUSHALL") }) 29 | }) 30 | } 31 | 32 | func TestKeyBuilder(t *testing.T) { 33 | Convey("It should return prefix with suffix if prefix", t, func() { 34 | client, err := NewClient(testredis) 35 | So(err, ShouldBeNil) 36 | 37 | client.KeyPrefix = "prefix" 38 | So(client.key("suffix"), ShouldEqual, "prefix:suffix") 39 | 40 | Convey("It should join multiple suffixes", func() { 41 | So(client.key("one", "two"), ShouldEqual, "prefix:one:two") 42 | }) 43 | }) 44 | 45 | Convey("It should return just suffix if no prefix", t, func() { 46 | client, err := NewClient(testredis) 47 | So(err, ShouldBeNil) 48 | 49 | client.KeyPrefix = "" 50 | So(client.key("suffix"), ShouldEqual, "suffix") 51 | 52 | Convey("It should join multiple suffixes", func() { 53 | So(client.key("one", "two"), ShouldEqual, "one:two") 54 | }) 55 | }) 56 | } 57 | 58 | func TestUserListKey(t *testing.T) { 59 | Convey("It should return user list key", t, func() { 60 | client, err := NewClient(testredis) 61 | So(err, ShouldBeNil) 62 | 63 | client.KeyPrefix = "" 64 | So(client.userListKey(), ShouldEqual, "user-list") 65 | }) 66 | } 67 | 68 | func TestOperation(t *testing.T) { 69 | Convey("", t, func() { 70 | client, err := NewClient(testredis) 71 | So(err, ShouldBeNil) 72 | 73 | Convey("It should do specified operation", func() { 74 | k1 := "testkey1" 75 | _, err = client.store.Do("SETBIT", k1, 1, On) 76 | So(err, ShouldBeNil) 77 | 78 | k2 := "testkey2" 79 | _, err = client.store.Do("SETBIT", k2, 2, On) 80 | So(err, ShouldBeNil) 81 | 82 | keys := []string{k1, k2} 83 | 84 | count, err := client.Operation(AND, keys...) 85 | So(err, ShouldBeNil) 86 | So(count, ShouldEqual, 0) 87 | 88 | count, err = client.Operation(OR, keys...) 89 | So(err, ShouldBeNil) 90 | So(count, ShouldEqual, 2) 91 | 92 | count, err = client.Operation(XOR, keys...) 93 | So(err, ShouldBeNil) 94 | So(count, ShouldEqual, 2) 95 | 96 | count, err = client.Operation(NOT, k1) 97 | So(err, ShouldBeNil) 98 | So(count, ShouldEqual, 7) 99 | 100 | Reset(func() { client.store.Do("FLUSHALL") }) 101 | }) 102 | 103 | Convey("It should accept only one op for NOT", func() { 104 | _, err := client.Operation(NOT, "k1", "k2") 105 | So(err, ShouldEqual, ErrNotOpAcceptsOnekey) 106 | }) 107 | }) 108 | } 109 | -------------------------------------------------------------------------------- /user_test.go: -------------------------------------------------------------------------------- 1 | package bitesized 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/smartystreets/goconvey/convey" 7 | ) 8 | 9 | func TestIsUserNew(t *testing.T) { 10 | Convey("", t, func() { 11 | client, err := NewClient(testredis) 12 | So(err, ShouldBeNil) 13 | 14 | Convey("It should return true if user is new", func() { 15 | isNew, err := client.IsUserNew(user) 16 | So(err, ShouldBeNil) 17 | 18 | So(isNew, ShouldBeTrue) 19 | }) 20 | 21 | Convey("It should return false if user isn't new", func() { 22 | _, err := client.getOrSetUser(user) 23 | So(err, ShouldBeNil) 24 | 25 | isNew, err := client.IsUserNew(user) 26 | So(err, ShouldBeNil) 27 | 28 | So(isNew, ShouldBeFalse) 29 | 30 | Reset(func() { client.store.Do("FLUSHALL") }) 31 | }) 32 | }) 33 | } 34 | 35 | func TestGetOrSetUser(t *testing.T) { 36 | Convey("It should save user if new user", t, func() { 37 | client, err := NewClient(testredis) 38 | So(err, ShouldBeNil) 39 | 40 | id, err := client.getOrSetUser(user) 41 | So(err, ShouldBeNil) 42 | So(id, ShouldEqual, 1) 43 | 44 | Convey("It should save user if new user", func() { 45 | id, err = client.getOrSetUser(user + "1") 46 | So(err, ShouldBeNil) 47 | So(id, ShouldEqual, 2) 48 | }) 49 | 50 | Convey("It should get user if existing user", func() { 51 | id, err := client.getOrSetUser(user) 52 | So(err, ShouldBeNil) 53 | So(id, ShouldEqual, 1) 54 | }) 55 | 56 | Convey("It should get existing user by id", func() { 57 | id, err := client.getOrSetUser(user) 58 | So(err, ShouldBeNil) 59 | 60 | username, err := client.getUserById(id) 61 | So(err, ShouldBeNil) 62 | So(username, ShouldEqual, user) 63 | }) 64 | 65 | Reset(func() { client.store.Do("FLUSHALL") }) 66 | }) 67 | } 68 | 69 | func TestEventUsers(t *testing.T) { 70 | Convey("It should return list of users who did an event", t, func() { 71 | client, err := NewClient(testredis) 72 | So(err, ShouldBeNil) 73 | 74 | client.Intervals = []Interval{Hour} 75 | 76 | err = client.TrackEvent("dodge rock", user, randomTime) 77 | So(err, ShouldBeNil) 78 | 79 | err = client.TrackEvent("dodge rock", user+"1", randomTime) 80 | So(err, ShouldBeNil) 81 | 82 | users, err := client.EventUsers("dodge rock", randomTime, Hour) 83 | So(err, ShouldBeNil) 84 | 85 | So(len(users), ShouldEqual, 2) 86 | So(users[0], ShouldEqual, user) 87 | So(users[1], ShouldEqual, user+"1") 88 | 89 | Reset(func() { client.store.Do("FLUSHALL") }) 90 | }) 91 | } 92 | 93 | func TestRemoveUser(t *testing.T) { 94 | Convey("It should remove user", t, func() { 95 | client, err := NewClient(testredis) 96 | So(err, ShouldBeNil) 97 | 98 | client.Intervals = []Interval{Hour, Day} 99 | 100 | err = client.TrackEvent("dodge rock", user, randomTime) 101 | So(err, ShouldBeNil) 102 | 103 | err = client.RemoveUser(user) 104 | So(err, ShouldBeNil) 105 | 106 | didEvent, err := client.DidEvent("dodge rock", user, randomTime, Hour) 107 | So(err, ShouldBeNil) 108 | 109 | So(didEvent, ShouldBeFalse) 110 | 111 | Reset(func() { client.store.Do("FLUSHALL") }) 112 | }) 113 | } 114 | -------------------------------------------------------------------------------- /interval_test.go: -------------------------------------------------------------------------------- 1 | package bitesized 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | . "github.com/smartystreets/goconvey/convey" 8 | ) 9 | 10 | var randomTime = time.Date(1981, time.June, 12, 01, 42, 0, 0, time.UTC) 11 | 12 | func TestNearestInterval(t *testing.T) { 13 | Convey("It should empty for 'All'", t, func() { 14 | n := nearestInterval(randomTime, All) 15 | So(n, ShouldEqual, "all") 16 | }) 17 | 18 | Convey("It should find nearest hour", t, func() { 19 | n := nearestInterval(randomTime, Hour) 20 | So(n, ShouldEqual, "hour:1981-06-12-01:00") 21 | }) 22 | 23 | Convey("It should find nearest day", t, func() { 24 | n := nearestInterval(randomTime, Day) 25 | So(n, ShouldEqual, "day:1981-06-12") 26 | }) 27 | 28 | Convey("It should find nearest week", t, func() { 29 | n := nearestInterval(randomTime, Week) 30 | So(n, ShouldEqual, "week:1981-06-07") 31 | }) 32 | 33 | Convey("It should find nearest month", t, func() { 34 | n := nearestInterval(randomTime, Month) 35 | So(n, ShouldEqual, "month:1981-06") 36 | }) 37 | 38 | Convey("It should find nearest year", t, func() { 39 | n := nearestInterval(randomTime, Year) 40 | So(n, ShouldEqual, "year:1981") 41 | }) 42 | 43 | Convey("It should find nearest quarter", t, func() { 44 | n := nearestInterval(randomTime, Quarter) 45 | So(n, ShouldEqual, "quarter:1981-04") 46 | }) 47 | 48 | Convey("It should find nearest 10 minute cycle", t, func() { 49 | n := nearestInterval(randomTime, TenMinutes) 50 | So(n, ShouldEqual, "ten_minutes:1981-06-12-01:40") 51 | }) 52 | 53 | Convey("It should find nearest 30 minute cycle", t, func() { 54 | n := nearestInterval(randomTime, ThirtyMinutes) 55 | So(n, ShouldEqual, "thirty_minutes:1981-06-12-01:30") 56 | }) 57 | 58 | Convey("It should find nearest biweekly date (first part)", t, func() { 59 | testingTime := time.Date(1981, time.June, 12, 01, 42, 0, 0, time.UTC) 60 | n := nearestInterval(testingTime, Biweekly) 61 | So(n, ShouldEqual, "biweekly:1981-06-10") 62 | }) 63 | 64 | Convey("It should find nearest biweekly date (second part)", t, func() { 65 | testingTime := time.Date(1981, time.June, 16, 01, 42, 0, 0, time.UTC) 66 | n := nearestInterval(testingTime, Biweekly) 67 | So(n, ShouldEqual, "biweekly:1981-06-14") 68 | }) 69 | 70 | Convey("It should find nearest bimonthly date (first part)", t, func() { 71 | testingTime := time.Date(1981, time.June, 12, 01, 42, 0, 0, time.UTC) 72 | n := nearestInterval(testingTime, Bimonthly) 73 | So(n, ShouldEqual, "bimonthly:1981-06-01") 74 | }) 75 | 76 | Convey("It should find nearest bimonthly date (second part)", t, func() { 77 | testingTime := time.Date(1981, time.June, 28, 01, 42, 0, 0, time.UTC) 78 | n := nearestInterval(testingTime, Bimonthly) 79 | So(n, ShouldEqual, "bimonthly:1981-06-15") 80 | }) 81 | 82 | } 83 | 84 | func TestGetDuration(t *testing.T) { 85 | Convey("It should return duration for hour", t, func() { 86 | d := getDuration(randomTime, Hour) 87 | So(d, ShouldEqual, 1*time.Hour) 88 | }) 89 | 90 | Convey("It should return duration for day", t, func() { 91 | d := getDuration(randomTime, Day) 92 | So(d, ShouldEqual, 24*time.Hour) 93 | }) 94 | 95 | Convey("It should return duration for week", t, func() { 96 | d := getDuration(randomTime, Week) 97 | So(d, ShouldEqual, 7*24*time.Hour) 98 | }) 99 | 100 | Convey("It should return duration for month with 31 days", t, func() { 101 | t := time.Date(2015, time.January, 01, 00, 0, 0, 0, time.UTC) 102 | d := getDuration(t, Month) 103 | 104 | So(d, ShouldEqual, 31*24*time.Hour) 105 | }) 106 | 107 | Convey("It should return duration for month with 30 days", t, func() { 108 | t := time.Date(2015, time.April, 01, 00, 0, 0, 0, time.UTC) 109 | d := getDuration(t, Month) 110 | 111 | So(d, ShouldEqual, 30*24*time.Hour) 112 | }) 113 | 114 | Convey("It should return duration for month with 28 days", t, func() { 115 | t := time.Date(2015, time.February, 01, 00, 0, 0, 0, time.UTC) 116 | d := getDuration(t, Month) 117 | 118 | So(d, ShouldEqual, 28*24*time.Hour) 119 | }) 120 | } 121 | -------------------------------------------------------------------------------- /retention_test.go: -------------------------------------------------------------------------------- 1 | package bitesized 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | . "github.com/smartystreets/goconvey/convey" 8 | ) 9 | 10 | func TestRetention(t *testing.T) { 11 | Convey("", t, func() { 12 | client, err := NewClient(testredis) 13 | So(err, ShouldBeNil) 14 | 15 | client.Intervals = []Interval{Hour} 16 | 17 | from := time.Date(2015, time.January, 2, 0, 1, 0, 0, time.UTC) 18 | till := from.Add(5 * time.Hour) 19 | 20 | data := map[string][]time.Time{ 21 | "user1": []time.Time{ 22 | from, 23 | from.Add(1 * time.Hour), 24 | from.Add(2 * time.Hour), 25 | from.Add(4 * time.Hour), 26 | from.Add(5 * time.Hour), 27 | }, 28 | "user2": []time.Time{ 29 | from.Add(1 * time.Hour), 30 | }, 31 | "user3": []time.Time{ 32 | from.Add(4 * time.Hour), 33 | from.Add(5 * time.Hour), 34 | }, 35 | } 36 | 37 | Convey("It should error if from is after till", func() { 38 | till := from.Add(1 * time.Hour) 39 | 40 | _, err := client.Retention("dodge rock", till, from, Hour, 1) 41 | So(err, ShouldEqual, ErrFromAfterTill) 42 | }) 43 | 44 | Convey("It should return result with empty values", func() { 45 | retention, err := client.Retention("dodge rock", from, till, Hour, 5) 46 | So(err, ShouldBeNil) 47 | 48 | So(len(retention), ShouldEqual, 6) 49 | 50 | for _, counts := range retention[0] { 51 | So(counts, ShouldContain, 0) 52 | So(counts, ShouldNotContain, 1) 53 | } 54 | }) 55 | 56 | Convey("It should return result with values", func() { 57 | for user, times := range data { 58 | for _, t := range times { 59 | err := client.TrackEvent("dodge rock", user, t) 60 | So(err, ShouldBeNil) 61 | } 62 | } 63 | 64 | retention, err := client.Retention("dodge rock", from, till, Hour, 5) 65 | So(err, ShouldBeNil) 66 | 67 | So(len(retention), ShouldEqual, 6) 68 | 69 | for _, counts := range retention[0] { 70 | So(len(counts), ShouldEqual, 5) 71 | 72 | So(counts[0], ShouldEqual, 1) 73 | So(counts[1], ShouldEqual, 1) 74 | So(counts[2], ShouldEqual, 1) 75 | So(counts[3], ShouldEqual, 0) 76 | So(counts[4], ShouldEqual, 0) 77 | } 78 | 79 | for _, counts := range retention[1] { 80 | So(len(counts), ShouldEqual, 5) 81 | 82 | So(counts[0], ShouldEqual, 2) 83 | So(counts[1], ShouldEqual, 1) 84 | So(counts[2], ShouldEqual, 0) 85 | So(counts[3], ShouldEqual, 0) 86 | So(counts[4], ShouldEqual, 0) 87 | } 88 | 89 | for _, counts := range retention[1] { 90 | So(len(counts), ShouldEqual, 5) 91 | 92 | So(counts[0], ShouldEqual, 2) 93 | So(counts[1], ShouldEqual, 1) 94 | So(counts[2], ShouldEqual, 0) 95 | So(counts[3], ShouldEqual, 0) 96 | So(counts[4], ShouldEqual, 0) 97 | } 98 | 99 | for _, counts := range retention[2] { 100 | So(len(counts), ShouldEqual, 4) 101 | 102 | So(counts[0], ShouldEqual, 1) 103 | So(counts[1], ShouldEqual, 0) 104 | So(counts[2], ShouldEqual, 0) 105 | So(counts[3], ShouldEqual, 0) 106 | } 107 | 108 | for _, counts := range retention[3] { 109 | So(len(counts), ShouldEqual, 3) 110 | 111 | So(counts[0], ShouldEqual, 0) 112 | So(counts[1], ShouldEqual, 0) 113 | So(counts[2], ShouldEqual, 0) 114 | } 115 | 116 | for _, counts := range retention[4] { 117 | So(len(counts), ShouldEqual, 2) 118 | 119 | So(counts[0], ShouldEqual, 2) 120 | So(counts[1], ShouldEqual, 2) 121 | } 122 | 123 | for _, counts := range retention[5] { 124 | So(len(counts), ShouldEqual, 1) 125 | 126 | So(counts[0], ShouldEqual, 2) 127 | } 128 | 129 | Convey("It should results as percentages", func() { 130 | retention, err := client.RetentionPercent("dodge rock", from, till, Hour, 5) 131 | So(err, ShouldBeNil) 132 | 133 | for _, counts := range retention[0] { 134 | So(len(counts), ShouldEqual, 5) 135 | 136 | So(counts[0], ShouldEqual, 1) 137 | So(counts[1], ShouldEqual, 1) 138 | So(counts[2], ShouldEqual, 1) 139 | So(counts[3], ShouldEqual, 0) 140 | So(counts[4], ShouldEqual, 0) 141 | } 142 | 143 | for _, counts := range retention[1] { 144 | So(len(counts), ShouldEqual, 5) 145 | 146 | So(counts[0], ShouldEqual, 2) 147 | So(counts[1], ShouldEqual, .5) 148 | So(counts[2], ShouldEqual, 0) 149 | So(counts[3], ShouldEqual, 0) 150 | So(counts[4], ShouldEqual, 0) 151 | } 152 | 153 | for _, counts := range retention[2] { 154 | So(len(counts), ShouldEqual, 4) 155 | 156 | So(counts[0], ShouldEqual, 1) 157 | So(counts[1], ShouldEqual, 0) 158 | So(counts[2], ShouldEqual, 0) 159 | So(counts[3], ShouldEqual, 0) 160 | } 161 | 162 | for _, counts := range retention[3] { 163 | So(len(counts), ShouldEqual, 3) 164 | 165 | So(counts[0], ShouldEqual, 0) 166 | So(counts[1], ShouldEqual, 0) 167 | So(counts[2], ShouldEqual, 0) 168 | } 169 | 170 | for _, counts := range retention[4] { 171 | So(len(counts), ShouldEqual, 2) 172 | 173 | So(counts[0], ShouldEqual, 2) 174 | So(counts[1], ShouldEqual, 1) 175 | } 176 | 177 | for _, counts := range retention[5] { 178 | So(len(counts), ShouldEqual, 1) 179 | 180 | So(counts[0], ShouldEqual, 2) 181 | } 182 | 183 | Reset(func() { client.store.Do("FLUSHALL") }) 184 | }) 185 | }) 186 | }) 187 | } 188 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # bitesized 2 | 3 | bitesized is a library that uses redis's bit operations to store and calculate analytics. It comes with a http server that can be used as an stand alone api (not implemented yet). 4 | 5 | ## Motivation 6 | 7 | It started when I saw a [blog post](http://blog.getspool.com/2011/11/29/fast-easy-realtime-metrics-using-redis-bitmaps/) about using redis bitmaps to store user event data. It sounded pretty neat and simple, not to mention fun, to implement. 8 | 9 | This project started as simple wrapper around bit operations, but has since taken a life of its own. I'm currently in the process of adding functionality that's a level higher, ie provide user analytics, not just store data. 10 | 11 | ## Install 12 | 13 | `go get github.com/sent-hil/bitesized` 14 | 15 | ## Usage 16 | 17 | Initialize client: 18 | 19 | ```go 20 | package main 21 | 22 | import ( 23 | "github.com/sent-hil/bitesized" 24 | ) 25 | 26 | func main() { 27 | redisuri := "localhost:6379" 28 | client, err := bitesized.NewClient(redisuri) 29 | } 30 | ``` 31 | 32 | Optionally, set intervals you want to track; by default these intervals are tracked: `all, daily, weekly and monthly`: 33 | 34 | ```go 35 | client.Intervals = []Interval{ 36 | bitesized.All, bitesized.Hour, bitesized.Day, bitesized.Week, bitesized.Month, bitesized.Year 37 | } 38 | ``` 39 | 40 | Optionally, set prefix to use for ALL keys; defaults to `bitesized`: 41 | 42 | ```go 43 | client.KeyPrefix = "bitesized" 44 | ``` 45 | 46 | Track an event that an user did: 47 | 48 | ```go 49 | err = client.TrackEvent("dodge rock", "indianajones", time.Now()) 50 | ``` 51 | 52 | If `indianajones` above is a new user, `user-counter` key is incremented and value stored in `user-list` key. That id is used as bit offset for events. 53 | 54 | This approach, as opposed to be checksum, enables us to take advantage of all offsets in a key in the beginning. However, as time goes on and old users generate less and less events, bit offsets will be wasted. In future sparse bitmaps maybe used to reduce wasting of bits such as here: https://github.com/bilus/redis-bitops 55 | 56 | Get count of users who did an event on particular interval: 57 | 58 | ```go 59 | count, err = client.CountEvent("dodge rock", time.Now(), bitesized.Hour) 60 | ``` 61 | 62 | Check if user did an event for particular interval: 63 | 64 | ```go 65 | didEvent, err := client.DidEvent("dodge rock", "indianajones", time.Now(), bitesized.Hour) 66 | ``` 67 | 68 | Get retention for specified interval: 69 | 70 | ```go 71 | from := time.Date(2015, time.January, 1, 0, 0, 0, 0, time.UTC) 72 | till := time.Date(2015, time.January, 3, 0, 0, 0, 0, time.UTC) 73 | 74 | // this defines how many days of retention to return for each day 75 | // for example if your interval contains 20 days, but want to look 76 | // back only 10 days for each day in your interval 77 | numOfDaysToLookBack := 10 78 | 79 | rs, err := client.Retention("dodge rock", from, till, bitesized.Day, numOfDaysToLookBack) 80 | ``` 81 | 82 | This returns a result like below. The keys are sorted asc by time: 83 | 84 | ``` 85 | [ 86 | { "day:2015-01-01": [ 30, 15, 3 ] }, 87 | { "day:2015-01-02": [ 50, 10 ] }, 88 | { "day:2015-01-03": [ 67 ] } 89 | ] 90 | ``` 91 | 92 | Get retention for specified interval in percentages: 93 | 94 | ```go 95 | rs, err := client.RetentionPercent("dodge rock", from, till, bitesized.Day, 10) 96 | ``` 97 | 98 | This returns a result like below. The keys are sorted asc by time. The first entry is total number 99 | 100 | ``` 101 | [ 102 | { "day:2015-01-01": [ 30, .5, .01 ] }, 103 | { "day:2015-01-02": [ 50, .05 ] }, 104 | { "day:2015-01-03": [ 67 ] } 105 | ] 106 | ``` 107 | 108 | Get list of events: 109 | 110 | ```go 111 | // * returns all events 112 | events, err := client.GetEvents("*") 113 | 114 | // dodge* returns events with dodge prefix 115 | events, err := client.GetEvents("dodge*") 116 | ``` 117 | 118 | Check if user was seen before: 119 | 120 | ```go 121 | isUserNew, err := client.IsUserNew("indianajones") 122 | ``` 123 | 124 | Do a bitwise operation on key/keys: 125 | 126 | ```go 127 | count, err := client.Operation(bitesized.AND, "dodge rock", "dodge nazis") 128 | ``` 129 | 130 | Following operations are support: 131 | 132 | * AND 133 | * OR 134 | * XOR 135 | * NOT (only accepts 1 arg) 136 | 137 | Get list of users who did an event on particular time/interval: 138 | 139 | ```go 140 | // returns list of users who did 'dodge rock' event in the last hour 141 | users, err := client.EventUsers("dodge rock", time.Now(), Hour) 142 | ``` 143 | 144 | Untrack ALL events and ALL intervals for user. Note, the user isn't deleted from `user-list` hash. If new event is tracked for the user, it'll use the same bit offset as before. 145 | 146 | ```go 147 | err = client.RemoveUser("indianajones") 148 | ``` 149 | 150 | Untrack an event for user. This will only untrack the client specified intervals. 151 | 152 | ```go 153 | err = client.UntrackEvent("dodge rock", "indianajones", time.Now()) 154 | ``` 155 | 156 | ```go 157 | // returns list of users who did 'dodge rock' event in the last hour 158 | users, err := client.EventUsers("dodge rock", time.Now(), Hour) 159 | ``` 160 | 161 | # TODO 162 | 163 | * Make threadsafe by using redis conn pooling 164 | * Write blog post explaning bitmaps and this library 165 | * Retention starting with an event, then comeback as diff. event(s) 166 | * Cohorts: users who did this event, also did 167 | * List of events sorted DESC/ASC by user count 168 | * Http server 169 | * List of users who didn't do an event metric 170 | * Identify user with properties 171 | * Option to return user with identified properties for metrics 172 | * Total count of users metric 173 | * Add method to undo an event 174 | * Move to lua scripts wherever possible 175 | -------------------------------------------------------------------------------- /event_test.go: -------------------------------------------------------------------------------- 1 | package bitesized 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/garyburd/redigo/redis" 8 | . "github.com/smartystreets/goconvey/convey" 9 | ) 10 | 11 | func TestTrackUntrackEvent(t *testing.T) { 12 | Convey("", t, func() { 13 | client, err := NewClient(testredis) 14 | So(err, ShouldBeNil) 15 | 16 | Convey("It should return error for track unless event or user", func() { 17 | err = client.TrackEvent("", user, time.Now()) 18 | So(err, ShouldEqual, ErrInvalidArg) 19 | }) 20 | 21 | Convey("It should return error for untrack unless event or user", func() { 22 | err = client.UntrackEvent("dodge", "", time.Now()) 23 | So(err, ShouldEqual, ErrInvalidArg) 24 | }) 25 | }) 26 | 27 | Convey("", t, func() { 28 | client, err := NewClient(testredis) 29 | So(err, ShouldBeNil) 30 | 31 | client.Intervals = []Interval{Year} 32 | 33 | Convey("It should track event for single interval", func() { 34 | err = client.TrackEvent("dodge rock", user, randomTime) 35 | So(err, ShouldBeNil) 36 | 37 | bitvalue, err := redis.Int(client.store.Do("GETBIT", "bitesized:event:dodge-rock:year:1981", 1)) 38 | So(err, ShouldBeNil) 39 | So(bitvalue, ShouldEqual, 1) 40 | }) 41 | 42 | Convey("It should untrack event for single interval", func() { 43 | err = client.UntrackEvent("dodge rock", user, randomTime) 44 | So(err, ShouldBeNil) 45 | 46 | bitvalue, err := redis.Int(client.store.Do("GETBIT", "bitesized:event:dodge-rock:year:1981", 1)) 47 | So(err, ShouldBeNil) 48 | So(bitvalue, ShouldEqual, 0) 49 | 50 | Reset(func() { client.store.Do("FLUSHALL") }) 51 | }) 52 | }) 53 | 54 | Convey("", t, func() { 55 | keys := []string{ 56 | "bitesized:event:dodge-rock:hour:1981-06-12-01:00", 57 | "bitesized:event:dodge-rock:day:1981-06-12", 58 | "bitesized:event:dodge-rock:week:1981-06-07", 59 | "bitesized:event:dodge-rock:month:1981-06", 60 | "bitesized:event:dodge-rock:year:1981", 61 | } 62 | 63 | client, err := NewClient(testredis) 64 | So(err, ShouldBeNil) 65 | 66 | client.Intervals = []Interval{Hour, Day, Week, Month, Year} 67 | 68 | Convey("It should track event for multiple intervals", func() { 69 | err = client.TrackEvent("dodge rock", user, randomTime) 70 | So(err, ShouldBeNil) 71 | 72 | for _, k := range keys { 73 | bitvalue, err := redis.Int(client.store.Do("GETBIT", k, 1)) 74 | So(err, ShouldBeNil) 75 | So(bitvalue, ShouldEqual, 1) 76 | } 77 | }) 78 | 79 | Convey("It should untrack event for multiple intervals", func() { 80 | err = client.UntrackEvent("dodge rock", user, randomTime) 81 | So(err, ShouldBeNil) 82 | 83 | for _, k := range keys { 84 | bitvalue, err := redis.Int(client.store.Do("GETBIT", k, 1)) 85 | So(err, ShouldBeNil) 86 | So(bitvalue, ShouldEqual, 0) 87 | } 88 | }) 89 | 90 | Reset(func() { client.store.Do("FLUSHALL") }) 91 | }) 92 | } 93 | 94 | func TestCountEvent(t *testing.T) { 95 | Convey("", t, func() { 96 | client, err := NewClient(testredis) 97 | So(err, ShouldBeNil) 98 | 99 | client.Intervals = []Interval{Hour} 100 | 101 | Convey("It should return 0 if no user did event", func() { 102 | count, err := client.CountEvent("dodge rock", time.Now(), Hour) 103 | So(err, ShouldBeNil) 104 | 105 | So(count, ShouldEqual, 0) 106 | }) 107 | 108 | Convey("It should return count of users who did event", func() { 109 | err := client.TrackEvent("dodge rock", user, time.Now()) 110 | So(err, ShouldBeNil) 111 | 112 | count, err := client.CountEvent("dodge rock", time.Now(), Hour) 113 | So(err, ShouldBeNil) 114 | 115 | So(count, ShouldEqual, 1) 116 | 117 | Reset(func() { client.store.Do("FLUSHALL") }) 118 | }) 119 | }) 120 | } 121 | 122 | func TestDidEvent(t *testing.T) { 123 | Convey("", t, func() { 124 | client, err := NewClient(testredis) 125 | So(err, ShouldBeNil) 126 | 127 | client.Intervals = []Interval{Hour} 128 | 129 | Convey("It should return no if user didn't do event", func() { 130 | didEvent, err := client.DidEvent("dodge rock", user, time.Now(), Hour) 131 | So(err, ShouldBeNil) 132 | 133 | So(didEvent, ShouldBeFalse) 134 | }) 135 | 136 | Convey("It should return yes if user did event", func() { 137 | err = client.TrackEvent("dodge rock", user, time.Now()) 138 | So(err, ShouldBeNil) 139 | 140 | didEvent, err := client.DidEvent("dodge rock", user, time.Now(), Hour) 141 | So(err, ShouldBeNil) 142 | 143 | So(didEvent, ShouldBeTrue) 144 | 145 | Reset(func() { client.store.Do("FLUSHALL") }) 146 | }) 147 | }) 148 | } 149 | 150 | func TestGetEvents(t *testing.T) { 151 | Convey("", t, func() { 152 | client, err := NewClient(testredis) 153 | So(err, ShouldBeNil) 154 | 155 | err = client.TrackEvent("dodge rock", user, time.Now()) 156 | So(err, ShouldBeNil) 157 | 158 | err = client.TrackEvent("something other thing", user, time.Now()) 159 | So(err, ShouldBeNil) 160 | 161 | Convey("It should return list of all events", func() { 162 | events, err := client.GetEvents("*") 163 | So(err, ShouldBeNil) 164 | 165 | So(len(events), ShouldEqual, 2) 166 | }) 167 | 168 | Convey("It should return list of events with prefix", func() { 169 | events, err := client.GetEvents("dodge*") 170 | So(err, ShouldBeNil) 171 | 172 | So(len(events), ShouldEqual, 1) 173 | }) 174 | 175 | Convey("It should return list of events when no prefix", func() { 176 | client, err := NewClient(testredis) 177 | So(err, ShouldBeNil) 178 | 179 | client.KeyPrefix = "" 180 | 181 | err = client.TrackEvent("dodge rock", user, time.Now()) 182 | So(err, ShouldBeNil) 183 | 184 | events, err := client.GetEvents("dodge*") 185 | So(err, ShouldBeNil) 186 | 187 | So(len(events), ShouldEqual, 1) 188 | }) 189 | 190 | Reset(func() { client.store.Do("FLUSHALL") }) 191 | }) 192 | } 193 | --------------------------------------------------------------------------------