mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-08-09 18:26:10 +00:00
feat(stats): use queryable Mongo usage records
This commit is contained in:
@@ -20,7 +20,7 @@ test: ## Unit tests (no emulator required)
|
||||
MONGO_PORT ?= 27017
|
||||
test-mongo: mongo-local ## Run MongoDB tests against a local Mongo container
|
||||
MONGODB_TEST_URL=mongodb://127.0.0.1:$(MONGO_PORT) LOG_LEVEL=error \
|
||||
go test -race -count=1 ./internal/storage/...
|
||||
go test -race -count=1 ./internal/storage/... ./internal/modules/stats/...
|
||||
|
||||
# ---- Lint / Vet ------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ internal/telegram/ Telegram long-polling bot wrapper
|
||||
internal/cron/ in-process cron scheduler
|
||||
internal/modules/ Module framework, registry, dispatchers, modules
|
||||
internal/storage/ typed DocStore[T] (Provider + Typed); mongodb runtime + memory (tests). Values persist as flattened native BSON root documents
|
||||
internal/systemstate/ shared `system` collection metadata for startup migrations
|
||||
compose.yml Coolify self-host stack (single bot service)
|
||||
telegram-commands.json Manual Telegram command menu source
|
||||
docs/deploy-coolify-selfhosted.md Self-host deploy and operations guide
|
||||
@@ -61,7 +62,7 @@ go run ./cmd/server
|
||||
For integration tests (each skips when its emulator env var is unset):
|
||||
```sh
|
||||
make mongo-local # docker run mongo:7 on :27017
|
||||
make test-mongo # internal/storage typed-store tests against local MongoDB
|
||||
make test-mongo # MongoDB integration tests against local MongoDB
|
||||
```
|
||||
|
||||
## Test
|
||||
@@ -69,7 +70,7 @@ make test-mongo # internal/storage typed-store tests against local Mong
|
||||
```sh
|
||||
make vet # go vet
|
||||
make test # full unit suite (no emulator)
|
||||
make test-mongo # typed-store integration tests against local Mongo (requires Docker)
|
||||
make test-mongo # MongoDB integration tests against local Mongo (requires Docker)
|
||||
```
|
||||
|
||||
## Deploy
|
||||
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
"github.com/tiennm99/miti99bot/internal/modules/wordle"
|
||||
"github.com/tiennm99/miti99bot/internal/server"
|
||||
"github.com/tiennm99/miti99bot/internal/storage"
|
||||
"github.com/tiennm99/miti99bot/internal/systemstate"
|
||||
"github.com/tiennm99/miti99bot/internal/telegram"
|
||||
)
|
||||
|
||||
@@ -93,6 +94,11 @@ func main() {
|
||||
log.Fatal("storage init failed", "err", err)
|
||||
}
|
||||
defer closeProvider()
|
||||
systemColl := provider.Collection(systemstate.CollectionName)
|
||||
|
||||
if err := stats.InitStore(rootCtx, provider.Collection("stats"), systemColl); err != nil {
|
||||
log.Fatal("stats storage init failed", "err", err)
|
||||
}
|
||||
|
||||
b, err := telegram.NewBot(cfg.TelegramBotToken)
|
||||
if err != nil {
|
||||
|
||||
@@ -8,7 +8,7 @@ Run `miti99bot` as a long-lived container on [Coolify](https://coolify.io) with
|
||||
```
|
||||
Telegram <── long poll (getUpdates) ── container (outbound only)
|
||||
in-process scheduler ───────────────────> module crons
|
||||
MongoDB Atlas (db / one collection per module)
|
||||
MongoDB Atlas (db / one collection per module + system metadata)
|
||||
Coolify env vars (plain secrets)
|
||||
NO public ingress (polling = outbound only; no domain, no /webhook, no TLS in)
|
||||
```
|
||||
@@ -60,13 +60,19 @@ overrides are not supported in runtime env; modules use coded defaults.
|
||||
4. Copy the `mongodb+srv://…` connection string into `MONGO_URL` and put the
|
||||
db name in `MONGO_DATABASE`.
|
||||
|
||||
> Storage layout: one collection per module; each document is a flattened native
|
||||
> Storage layout: one collection per module plus a shared `system` collection
|
||||
> for startup metadata such as one-time migrations. Each document is a flattened native
|
||||
> document — `{ _id: <user key>, ...payload fields, version, updatedAt }` with no
|
||||
> `value` envelope. Payload fields are hoisted to the document root so they
|
||||
> expand and are queryable in Compass. The two non-object values are wrapped in a
|
||||
> named field: schedule subscribers under `subscribers` (array) and the daily
|
||||
> push date under `date`. Concurrency uses the `version` field (optimistic lock);
|
||||
> `updatedAt` is a BSON Date.
|
||||
>
|
||||
> The `stats` collection uses queryable aggregate documents for command/user
|
||||
> counts and creates indexes on startup. First startup after the schema change
|
||||
> migrates legacy `count:`, `user:`, and `pair:` stats keys into the new shape,
|
||||
> deletes the legacy keys, and records completion in `system`.
|
||||
|
||||
## 2. Coolify
|
||||
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
package stats
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
|
||||
"github.com/tiennm99/miti99bot/internal/storage"
|
||||
"github.com/tiennm99/miti99bot/internal/systemstate"
|
||||
)
|
||||
|
||||
const (
|
||||
legacyCountPrefix = "count:"
|
||||
legacyUserPrefix = "user:"
|
||||
legacyPairPrefix = "pair:"
|
||||
|
||||
usageMigrationName = "stats-usage-v2"
|
||||
usageMigrationKey = "migration:" + usageMigrationName
|
||||
)
|
||||
|
||||
type legacyCountEntry struct {
|
||||
N int64 `json:"n" bson:"n"`
|
||||
}
|
||||
|
||||
type legacyUserEntry struct {
|
||||
Username string `json:"username" bson:"username"`
|
||||
N int64 `json:"n" bson:"n"`
|
||||
}
|
||||
|
||||
// InitStore performs stats collection startup maintenance. It is safe to call
|
||||
// every boot: indexes are idempotent and legacy migration is guarded by a
|
||||
// system collection marker.
|
||||
func InitStore(ctx context.Context, statsColl, systemColl storage.Collection) error {
|
||||
if mongoColl, ok := storage.MongoCollection(statsColl); ok {
|
||||
if err := ensureUsageIndexes(ctx, mongoColl); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return migrateLegacyUsage(ctx, statsColl, systemColl)
|
||||
}
|
||||
|
||||
func ensureUsageIndexes(ctx context.Context, coll *mongo.Collection) error {
|
||||
models := []mongo.IndexModel{
|
||||
{
|
||||
Keys: bson.D{bsonField("cmd", 1), bsonField("n", -1), bsonField("user", 1)},
|
||||
Options: options.Index().SetName("stats_cmd_n_user"),
|
||||
},
|
||||
{
|
||||
Keys: bson.D{bsonField("uid", 1), bsonField("n", -1), bsonField("cmd", 1)},
|
||||
Options: options.Index().SetName("stats_uid_n_cmd"),
|
||||
},
|
||||
{
|
||||
Keys: bson.D{bsonField("user", 1), bsonField("updatedAt", -1)},
|
||||
Options: options.Index().SetName("stats_user_updated_at"),
|
||||
},
|
||||
}
|
||||
if _, err := coll.Indexes().CreateMany(ctx, models); err != nil {
|
||||
return fmt.Errorf("stats indexes: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateLegacyUsage(ctx context.Context, statsColl, systemColl storage.Collection) error {
|
||||
sys := systemstate.New(systemColl)
|
||||
if rec, ok, err := sys.Get(ctx, usageMigrationKey); err != nil {
|
||||
return fmt.Errorf("stats legacy migration marker get: %w", err)
|
||||
} else if ok && rec.Status == "done" {
|
||||
return nil
|
||||
}
|
||||
|
||||
legacyCounts := storage.Typed[legacyCountEntry](statsColl)
|
||||
legacyUsers := storage.Typed[legacyUserEntry](statsColl)
|
||||
usageDocs := storage.Typed[usageEntry](statsColl)
|
||||
|
||||
usernames, userKeys, err := loadLegacyUsernames(ctx, legacyUsers)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
countTotals, countKeys, err := loadLegacyCounts(ctx, legacyCounts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pairs, pairKeys, pairSums, err := loadLegacyPairs(ctx, legacyCounts, usernames)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
written := int64(0)
|
||||
for _, entry := range pairs {
|
||||
if err := usageDocs.Put(ctx, usageKey(entry.Cmd, entry.UserID), entry); err != nil {
|
||||
return fmt.Errorf("stats legacy pair put %s:%d: %w", entry.Cmd, entry.UserID, err)
|
||||
}
|
||||
written++
|
||||
}
|
||||
|
||||
for cmd, total := range countTotals {
|
||||
anonymous := total - pairSums[cmd]
|
||||
if anonymous <= 0 {
|
||||
continue
|
||||
}
|
||||
if err := usageDocs.Put(ctx, usageKey(cmd, 0), usageEntry{Cmd: cmd, N: anonymous}); err != nil {
|
||||
return fmt.Errorf("stats legacy anonymous put %s: %w", cmd, err)
|
||||
}
|
||||
written++
|
||||
}
|
||||
|
||||
for _, key := range append(append(countKeys, pairKeys...), userKeys...) {
|
||||
if err := legacyCounts.Delete(ctx, key); err != nil {
|
||||
return fmt.Errorf("stats legacy delete %s: %w", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
now := nowMillis()
|
||||
if err := sys.Put(ctx, usageMigrationKey, systemstate.Record{
|
||||
Kind: "migration",
|
||||
Name: usageMigrationName,
|
||||
Status: "done",
|
||||
Count: written,
|
||||
CompletedAt: now,
|
||||
UpdatedAt: now,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("stats legacy migration marker put: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadLegacyUsernames(ctx context.Context, users storage.DocStore[legacyUserEntry]) (map[int64]string, []string, error) {
|
||||
keys, err := users.List(ctx, legacyUserPrefix)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("stats legacy users list: %w", err)
|
||||
}
|
||||
usernames := make(map[int64]string, len(keys))
|
||||
for _, key := range keys {
|
||||
id, err := strconv.ParseInt(strings.TrimPrefix(key, legacyUserPrefix), 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
entry, _, err := users.Get(ctx, key)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("stats legacy user get %s: %w", key, err)
|
||||
}
|
||||
usernames[id] = entry.Username
|
||||
}
|
||||
return usernames, keys, nil
|
||||
}
|
||||
|
||||
func loadLegacyCounts(ctx context.Context, counts storage.DocStore[legacyCountEntry]) (map[string]int64, []string, error) {
|
||||
keys, err := counts.List(ctx, legacyCountPrefix)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("stats legacy counts list: %w", err)
|
||||
}
|
||||
totals := make(map[string]int64, len(keys))
|
||||
for _, key := range keys {
|
||||
cmd := strings.TrimPrefix(key, legacyCountPrefix)
|
||||
if cmd == "" {
|
||||
continue
|
||||
}
|
||||
entry, _, err := counts.Get(ctx, key)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("stats legacy count get %s: %w", key, err)
|
||||
}
|
||||
totals[cmd] = entry.N
|
||||
}
|
||||
return totals, keys, nil
|
||||
}
|
||||
|
||||
func loadLegacyPairs(ctx context.Context, counts storage.DocStore[legacyCountEntry], usernames map[int64]string) ([]usageEntry, []string, map[string]int64, error) {
|
||||
keys, err := counts.List(ctx, legacyPairPrefix)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("stats legacy pairs list: %w", err)
|
||||
}
|
||||
pairs := make([]usageEntry, 0, len(keys))
|
||||
pairSums := make(map[string]int64)
|
||||
for _, key := range keys {
|
||||
cmd, userID, ok := parseLegacyPairKey(key)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
entry, _, err := counts.Get(ctx, key)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("stats legacy pair get %s: %w", key, err)
|
||||
}
|
||||
pairs = append(pairs, usageEntry{
|
||||
Cmd: cmd,
|
||||
UserID: userID,
|
||||
Username: usernames[userID],
|
||||
N: entry.N,
|
||||
})
|
||||
pairSums[cmd] += entry.N
|
||||
}
|
||||
return pairs, keys, pairSums, nil
|
||||
}
|
||||
|
||||
func parseLegacyPairKey(key string) (string, int64, bool) {
|
||||
rest := strings.TrimPrefix(key, legacyPairPrefix)
|
||||
idx := strings.LastIndexByte(rest, ':')
|
||||
if idx <= 0 || idx == len(rest)-1 {
|
||||
return "", 0, false
|
||||
}
|
||||
id, err := strconv.ParseInt(rest[idx+1:], 10, 64)
|
||||
if err != nil {
|
||||
return "", 0, false
|
||||
}
|
||||
return rest[:idx], id, true
|
||||
}
|
||||
|
||||
func nowMillis() int64 {
|
||||
return time.Now().UnixMilli()
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package stats
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
|
||||
"github.com/tiennm99/miti99bot/internal/storage"
|
||||
"github.com/tiennm99/miti99bot/internal/systemstate"
|
||||
)
|
||||
|
||||
func TestInitStore_MongoCreatesIndexesAndMigratesLegacy(t *testing.T) {
|
||||
uri := os.Getenv("MONGODB_TEST_URL")
|
||||
if uri == "" {
|
||||
t.Skip("MONGODB_TEST_URL not set; skipping MongoDB integration test")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
client, err := storage.NewMongoClient(ctx, uri)
|
||||
if err != nil {
|
||||
t.Fatalf("NewMongoClient: %v", err)
|
||||
}
|
||||
dbName := fmt.Sprintf("miti99bot_stats_test_%d", time.Now().UnixNano())
|
||||
db := client.Database(dbName)
|
||||
defer func() {
|
||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_ = db.Drop(cleanupCtx)
|
||||
_ = client.Disconnect(cleanupCtx)
|
||||
}()
|
||||
|
||||
provider := storage.NewMongoProvider(db)
|
||||
statsColl := provider.Collection("stats")
|
||||
systemColl := provider.Collection(systemstate.CollectionName)
|
||||
legacyCounts := storage.Typed[legacyCountEntry](statsColl)
|
||||
legacyUsers := storage.Typed[legacyUserEntry](statsColl)
|
||||
if err := legacyCounts.Put(ctx, legacyCountPrefix+"ping", legacyCountEntry{N: 2}); err != nil {
|
||||
t.Fatalf("legacy count: %v", err)
|
||||
}
|
||||
if err := legacyUsers.Put(ctx, legacyUserPrefix+"7", legacyUserEntry{Username: "alice", N: 1}); err != nil {
|
||||
t.Fatalf("legacy user: %v", err)
|
||||
}
|
||||
if err := legacyCounts.Put(ctx, legacyPairPrefix+"ping:7", legacyCountEntry{N: 1}); err != nil {
|
||||
t.Fatalf("legacy pair: %v", err)
|
||||
}
|
||||
|
||||
if err := InitStore(ctx, statsColl, systemColl); err != nil {
|
||||
t.Fatalf("InitStore: %v", err)
|
||||
}
|
||||
|
||||
usageDocs := storage.Typed[usageEntry](statsColl)
|
||||
assertUsageEntry(t, usageDocs, usageKey("ping", 7), usageEntry{Cmd: "ping", UserID: 7, Username: "alice", N: 1})
|
||||
assertUsageEntry(t, usageDocs, usageKey("ping", 0), usageEntry{Cmd: "ping", N: 1})
|
||||
|
||||
rawStatsColl, ok := storage.MongoCollection(statsColl)
|
||||
if !ok {
|
||||
t.Fatal("stats collection is not Mongo-backed")
|
||||
}
|
||||
cur, err := rawStatsColl.Indexes().List(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("list indexes: %v", err)
|
||||
}
|
||||
defer func() { _ = cur.Close(ctx) }()
|
||||
|
||||
found := map[string]bool{}
|
||||
for cur.Next(ctx) {
|
||||
var doc struct {
|
||||
Name string `bson:"name"`
|
||||
}
|
||||
if err := cur.Decode(&doc); err != nil {
|
||||
t.Fatalf("decode index: %v", err)
|
||||
}
|
||||
found[doc.Name] = true
|
||||
}
|
||||
if err := cur.Err(); err != nil {
|
||||
t.Fatalf("index cursor: %v", err)
|
||||
}
|
||||
for _, name := range []string{"stats_cmd_n_user", "stats_uid_n_cmd", "stats_user_updated_at"} {
|
||||
if !found[name] {
|
||||
t.Fatalf("missing index %s; indexes=%v", name, found)
|
||||
}
|
||||
}
|
||||
|
||||
rawDoc := bson.M{}
|
||||
err = rawStatsColl.FindOne(ctx, bson.M{"_id": legacyCountPrefix + "ping"}).Decode(&rawDoc)
|
||||
if err == nil {
|
||||
t.Fatalf("legacy count key still exists: %+v", rawDoc)
|
||||
}
|
||||
if !errors.Is(err, mongo.ErrNoDocuments) {
|
||||
t.Fatalf("legacy count lookup err = %v, want ErrNoDocuments", err)
|
||||
}
|
||||
}
|
||||
+23
-108
@@ -1,12 +1,9 @@
|
||||
// Package stats tracks per-command and per-user invocation counts persistently
|
||||
// in DocStore and exposes /stats subcommands to display them sorted by popularity.
|
||||
// Package stats tracks command usage and exposes /stats subcommands sorted by
|
||||
// popularity.
|
||||
package stats
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/go-telegram/bot/models"
|
||||
|
||||
@@ -15,128 +12,46 @@ import (
|
||||
"github.com/tiennm99/miti99bot/internal/storage"
|
||||
)
|
||||
|
||||
// Sort-key shapes inside the stats module's partition:
|
||||
//
|
||||
// count:<cmd> → countEntry — total per command
|
||||
// user:<userID> → userEntry — cached username + total per user
|
||||
// pair:<cmd>:<userID> → countEntry — per (command, user) pair
|
||||
const (
|
||||
countPrefix = "count:"
|
||||
userPrefix = "user:"
|
||||
pairPrefix = "pair:"
|
||||
topK = 20
|
||||
)
|
||||
const topK = 20
|
||||
|
||||
type countEntry struct {
|
||||
N int64 `json:"n" bson:"n"`
|
||||
}
|
||||
|
||||
type userEntry struct {
|
||||
Username string `json:"username" bson:"username"`
|
||||
N int64 `json:"n" bson:"n"`
|
||||
}
|
||||
|
||||
// counter holds two typed views over the same module Collection:
|
||||
// one for countEntry values and one for userEntry values.
|
||||
// Keys are disjoint by prefix so both views safely share the collection.
|
||||
// counter owns the stats repository used by the command hook and render views.
|
||||
type counter struct {
|
||||
counts storage.DocStore[countEntry]
|
||||
users storage.DocStore[userEntry]
|
||||
store usageStore
|
||||
}
|
||||
|
||||
func countKey(name string) string { return countPrefix + name }
|
||||
|
||||
func userKey(id int64) string { return userPrefix + strconv.FormatInt(id, 10) }
|
||||
|
||||
func pairKey(cmd string, id int64) string {
|
||||
return pairPrefix + cmd + ":" + strconv.FormatInt(id, 10)
|
||||
}
|
||||
|
||||
// Inc fans out persistent counter writes for one command invocation.
|
||||
// Always increments count:<cmd>. When the originating user has a Telegram
|
||||
// username, also increments user:<id> (refreshing the cached username) and
|
||||
// pair:<cmd>:<id>. Errors are logged and swallowed; concurrent invocations of
|
||||
// the same (cmd, user) may lose updates — stats are best-effort. A future
|
||||
// backend atomic increment would close the race.
|
||||
// Inc records one authorized command invocation. A sender only contributes to
|
||||
// user-level stats when Telegram provides a public username; otherwise the
|
||||
// invocation still contributes to command totals.
|
||||
func (c *counter) Inc(ctx context.Context, name string, update *models.Update) {
|
||||
var (
|
||||
userID int64
|
||||
username string
|
||||
hasUser bool
|
||||
user usageUser
|
||||
hasUser bool
|
||||
)
|
||||
if update != nil && update.Message != nil && update.Message.From != nil && update.Message.From.Username != "" {
|
||||
userID = update.Message.From.ID
|
||||
username = update.Message.From.Username
|
||||
user = usageUser{
|
||||
ID: update.Message.From.ID,
|
||||
Username: update.Message.From.Username,
|
||||
}
|
||||
hasUser = true
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
c.incCount(ctx, name)
|
||||
}()
|
||||
if hasUser {
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
c.incUser(ctx, userID, username)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
c.incPair(ctx, name, userID)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func (c *counter) incCount(ctx context.Context, name string) {
|
||||
key := countKey(name)
|
||||
entry, _, err := c.counts.Get(ctx, key)
|
||||
if err != nil && !errors.Is(err, storage.ErrNotFound) {
|
||||
log.Error("stats: store get failed", "key", key, "err", err)
|
||||
return
|
||||
}
|
||||
entry.N++
|
||||
if err := c.counts.Put(ctx, key, entry); err != nil {
|
||||
log.Error("stats: store put failed", "key", key, "err", err)
|
||||
if err := c.store.Increment(ctx, name, user, hasUser); err != nil {
|
||||
if hasUser {
|
||||
log.Error("stats: increment failed", "command", name, "user_id", user.ID, "err", err)
|
||||
return
|
||||
}
|
||||
log.Error("stats: increment failed", "command", name, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *counter) incUser(ctx context.Context, id int64, username string) {
|
||||
key := userKey(id)
|
||||
entry, _, err := c.users.Get(ctx, key)
|
||||
if err != nil && !errors.Is(err, storage.ErrNotFound) {
|
||||
log.Error("stats: store get failed", "key", key, "err", err)
|
||||
return
|
||||
}
|
||||
entry.Username = username
|
||||
entry.N++
|
||||
if err := c.users.Put(ctx, key, entry); err != nil {
|
||||
log.Error("stats: store put failed", "key", key, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *counter) incPair(ctx context.Context, name string, id int64) {
|
||||
key := pairKey(name, id)
|
||||
entry, _, err := c.counts.Get(ctx, key)
|
||||
if err != nil && !errors.Is(err, storage.ErrNotFound) {
|
||||
log.Error("stats: store get failed", "key", key, "err", err)
|
||||
return
|
||||
}
|
||||
entry.N++
|
||||
if err := c.counts.Put(ctx, key, entry); err != nil {
|
||||
log.Error("stats: store put failed", "key", key, "err", err)
|
||||
}
|
||||
func newCounter(coll storage.Collection) *counter {
|
||||
return &counter{store: newUsageStore(coll)}
|
||||
}
|
||||
|
||||
// New is the module Factory. Registers a CommandHook that persists counts and
|
||||
// a /stats command that displays them.
|
||||
func New(deps modules.Deps) modules.Module {
|
||||
c := &counter{
|
||||
counts: storage.Typed[countEntry](deps.Store),
|
||||
users: storage.Typed[userEntry](deps.Store),
|
||||
}
|
||||
c := newCounter(deps.Store)
|
||||
return modules.Module{
|
||||
CommandHook: c.Inc,
|
||||
Commands: []modules.Command{
|
||||
|
||||
@@ -11,16 +11,14 @@ import (
|
||||
|
||||
"github.com/tiennm99/miti99bot/internal/modules"
|
||||
"github.com/tiennm99/miti99bot/internal/storage"
|
||||
"github.com/tiennm99/miti99bot/internal/systemstate"
|
||||
"github.com/tiennm99/miti99bot/internal/testutil"
|
||||
)
|
||||
|
||||
// newStatsCounter returns a fresh counter backed by an in-memory collection.
|
||||
func newStatsCounter() *counter {
|
||||
col := storage.NewMemoryProvider().Collection("stats")
|
||||
return &counter{
|
||||
counts: storage.Typed[countEntry](col),
|
||||
users: storage.Typed[userEntry](col),
|
||||
}
|
||||
return newCounter(col)
|
||||
}
|
||||
|
||||
func TestNew_RegistersExpectedCommands(t *testing.T) {
|
||||
@@ -57,99 +55,97 @@ func updateFrom(id int64, username string) *models.Update {
|
||||
|
||||
func TestInc_PersistsCountInStore(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
c := newStatsCounter()
|
||||
provider := storage.NewMemoryProvider()
|
||||
c := newCounter(provider.Collection("stats"))
|
||||
docs := storage.Typed[usageEntry](provider.Collection("stats"))
|
||||
|
||||
c.Inc(ctx, "ping", nil)
|
||||
c.Inc(ctx, "ping", nil)
|
||||
c.Inc(ctx, "wordle", nil)
|
||||
|
||||
entry, _, err := c.counts.Get(ctx, countKey("ping"))
|
||||
entry, _, err := docs.Get(ctx, usageKey("ping", 0))
|
||||
if err != nil {
|
||||
t.Fatalf("Get ping: %v", err)
|
||||
}
|
||||
if entry.N != 2 {
|
||||
t.Errorf("ping count = %d, want 2", entry.N)
|
||||
if entry.Cmd != "ping" || entry.N != 2 || entry.UserID != 0 || entry.Username != "" {
|
||||
t.Errorf("ping entry = %+v, want anonymous ping count 2", entry)
|
||||
}
|
||||
|
||||
entry2, _, err := c.counts.Get(ctx, countKey("wordle"))
|
||||
entry2, _, err := docs.Get(ctx, usageKey("wordle", 0))
|
||||
if err != nil {
|
||||
t.Fatalf("Get wordle: %v", err)
|
||||
}
|
||||
if entry2.N != 1 {
|
||||
t.Errorf("wordle count = %d, want 1", entry2.N)
|
||||
if entry2.Cmd != "wordle" || entry2.N != 1 {
|
||||
t.Errorf("wordle entry = %+v, want count 1", entry2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInc_WithUsernameWritesAllThreeKeys(t *testing.T) {
|
||||
func TestInc_WithUsernameWritesPairEntry(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
c := newStatsCounter()
|
||||
provider := storage.NewMemoryProvider()
|
||||
c := newCounter(provider.Collection("stats"))
|
||||
docs := storage.Typed[usageEntry](provider.Collection("stats"))
|
||||
|
||||
c.Inc(ctx, "ping", updateFrom(42, "alice"))
|
||||
c.Inc(ctx, "ping", updateFrom(42, "alice"))
|
||||
|
||||
ce, _, err := c.counts.Get(ctx, countKey("ping"))
|
||||
entry, _, err := docs.Get(ctx, usageKey("ping", 42))
|
||||
if err != nil {
|
||||
t.Fatalf("count:ping: %v", err)
|
||||
t.Fatalf("ping:42: %v", err)
|
||||
}
|
||||
if ce.N != 2 {
|
||||
t.Errorf("count:ping N = %d, want 2", ce.N)
|
||||
if entry.Cmd != "ping" || entry.UserID != 42 || entry.Username != "alice" || entry.N != 2 {
|
||||
t.Errorf("ping:42 = %+v, want alice pair count 2", entry)
|
||||
}
|
||||
|
||||
ue, _, err := c.users.Get(ctx, userKey(42))
|
||||
if err != nil {
|
||||
t.Fatalf("user:42: %v", err)
|
||||
}
|
||||
if ue.N != 2 || ue.Username != "alice" {
|
||||
t.Errorf("user:42 = {%q, %d}, want {alice, 2}", ue.Username, ue.N)
|
||||
}
|
||||
|
||||
pe, _, err := c.counts.Get(ctx, pairKey("ping", 42))
|
||||
if err != nil {
|
||||
t.Fatalf("pair:ping:42: %v", err)
|
||||
}
|
||||
if pe.N != 2 {
|
||||
t.Errorf("pair:ping:42 N = %d, want 2", pe.N)
|
||||
if _, _, err := docs.Get(ctx, usageKey("ping", 0)); !errors.Is(err, storage.ErrNotFound) {
|
||||
t.Errorf("named user should not create anonymous ping bucket, got err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInc_EmptyUsernameSkipsUserAndPair(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
c := newStatsCounter()
|
||||
provider := storage.NewMemoryProvider()
|
||||
c := newCounter(provider.Collection("stats"))
|
||||
docs := storage.Typed[usageEntry](provider.Collection("stats"))
|
||||
|
||||
c.Inc(ctx, "ping", updateFrom(42, ""))
|
||||
|
||||
ce, _, err := c.counts.Get(ctx, countKey("ping"))
|
||||
entry, _, err := docs.Get(ctx, usageKey("ping", 0))
|
||||
if err != nil {
|
||||
t.Fatalf("count:ping: %v", err)
|
||||
t.Fatalf("ping: %v", err)
|
||||
}
|
||||
if ce.N != 1 {
|
||||
t.Errorf("count:ping N = %d, want 1", ce.N)
|
||||
if entry.N != 1 || entry.UserID != 0 || entry.Username != "" {
|
||||
t.Errorf("anonymous ping entry = %+v, want count 1 without user", entry)
|
||||
}
|
||||
|
||||
if _, _, err := c.users.Get(ctx, userKey(42)); !errors.Is(err, storage.ErrNotFound) {
|
||||
t.Errorf("user:42 should be absent, got err=%v", err)
|
||||
}
|
||||
if _, _, err := c.counts.Get(ctx, pairKey("ping", 42)); !errors.Is(err, storage.ErrNotFound) {
|
||||
t.Errorf("pair:ping:42 should be absent, got err=%v", err)
|
||||
if _, _, err := docs.Get(ctx, usageKey("ping", 42)); !errors.Is(err, storage.ErrNotFound) {
|
||||
t.Errorf("ping:42 should be absent, got err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInc_RefreshesUsernameOnRename(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
c := newStatsCounter()
|
||||
provider := storage.NewMemoryProvider()
|
||||
c := newCounter(provider.Collection("stats"))
|
||||
docs := storage.Typed[usageEntry](provider.Collection("stats"))
|
||||
|
||||
c.Inc(ctx, "ping", updateFrom(42, "alice"))
|
||||
c.Inc(ctx, "wordle", updateFrom(42, "alice"))
|
||||
c.Inc(ctx, "ping", updateFrom(42, "alice2"))
|
||||
|
||||
ue, _, err := c.users.Get(ctx, userKey(42))
|
||||
ping, _, err := docs.Get(ctx, usageKey("ping", 42))
|
||||
if err != nil {
|
||||
t.Fatalf("user:42: %v", err)
|
||||
t.Fatalf("ping:42: %v", err)
|
||||
}
|
||||
if ue.Username != "alice2" {
|
||||
t.Errorf("user:42 Username = %q, want %q", ue.Username, "alice2")
|
||||
if ping.Username != "alice2" || ping.N != 2 {
|
||||
t.Errorf("ping:42 = %+v, want alice2 count 2", ping)
|
||||
}
|
||||
if ue.N != 2 {
|
||||
t.Errorf("user:42 N = %d, want 2", ue.N)
|
||||
wordle, _, err := docs.Get(ctx, usageKey("wordle", 42))
|
||||
if err != nil {
|
||||
t.Fatalf("wordle:42: %v", err)
|
||||
}
|
||||
if wordle.Username != "alice2" || wordle.N != 1 {
|
||||
t.Errorf("wordle:42 = %+v, want refreshed alice2 count 1", wordle)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,13 +228,98 @@ func TestCommandHook_FiredThroughModulesBuild(t *testing.T) {
|
||||
reg.RunCommandHooks(ctx, "ping", nil)
|
||||
|
||||
// Verify by reading back via typed store (the same collection Build gave to stats).
|
||||
statsStore := storage.Typed[countEntry](provider.Collection("stats"))
|
||||
entry, _, err := statsStore.Get(ctx, countKey("ping"))
|
||||
statsStore := storage.Typed[usageEntry](provider.Collection("stats"))
|
||||
entry, _, err := statsStore.Get(ctx, usageKey("ping", 0))
|
||||
if err != nil {
|
||||
t.Fatalf("expected count:ping in store after hook: %v", err)
|
||||
t.Fatalf("expected ping in store after hook: %v", err)
|
||||
}
|
||||
if entry.N != 1 {
|
||||
t.Errorf("count:ping = %d, want 1", entry.N)
|
||||
t.Errorf("ping = %d, want 1", entry.N)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitStore_MigratesLegacyStatsOnceAndDeletesOldKeys(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
provider := storage.NewMemoryProvider()
|
||||
statsColl := provider.Collection("stats")
|
||||
systemColl := provider.Collection(systemstate.CollectionName)
|
||||
legacyCounts := storage.Typed[legacyCountEntry](statsColl)
|
||||
legacyUsers := storage.Typed[legacyUserEntry](statsColl)
|
||||
|
||||
if err := legacyCounts.Put(ctx, legacyCountPrefix+"ping", legacyCountEntry{N: 6}); err != nil {
|
||||
t.Fatalf("legacy count ping: %v", err)
|
||||
}
|
||||
if err := legacyCounts.Put(ctx, legacyCountPrefix+"wordle", legacyCountEntry{N: 2}); err != nil {
|
||||
t.Fatalf("legacy count wordle: %v", err)
|
||||
}
|
||||
if err := legacyUsers.Put(ctx, legacyUserPrefix+"1", legacyUserEntry{Username: "alice", N: 4}); err != nil {
|
||||
t.Fatalf("legacy user alice: %v", err)
|
||||
}
|
||||
if err := legacyUsers.Put(ctx, legacyUserPrefix+"2", legacyUserEntry{Username: "bob", N: 3}); err != nil {
|
||||
t.Fatalf("legacy user bob: %v", err)
|
||||
}
|
||||
if err := legacyCounts.Put(ctx, legacyPairPrefix+"ping:1", legacyCountEntry{N: 3}); err != nil {
|
||||
t.Fatalf("legacy pair ping alice: %v", err)
|
||||
}
|
||||
if err := legacyCounts.Put(ctx, legacyPairPrefix+"ping:2", legacyCountEntry{N: 2}); err != nil {
|
||||
t.Fatalf("legacy pair ping bob: %v", err)
|
||||
}
|
||||
if err := legacyCounts.Put(ctx, legacyPairPrefix+"wordle:2", legacyCountEntry{N: 2}); err != nil {
|
||||
t.Fatalf("legacy pair wordle bob: %v", err)
|
||||
}
|
||||
|
||||
if err := InitStore(ctx, statsColl, systemColl); err != nil {
|
||||
t.Fatalf("InitStore: %v", err)
|
||||
}
|
||||
|
||||
usageDocs := storage.Typed[usageEntry](statsColl)
|
||||
assertUsageEntry(t, usageDocs, usageKey("ping", 1), usageEntry{Cmd: "ping", UserID: 1, Username: "alice", N: 3})
|
||||
assertUsageEntry(t, usageDocs, usageKey("ping", 2), usageEntry{Cmd: "ping", UserID: 2, Username: "bob", N: 2})
|
||||
assertUsageEntry(t, usageDocs, usageKey("wordle", 2), usageEntry{Cmd: "wordle", UserID: 2, Username: "bob", N: 2})
|
||||
assertUsageEntry(t, usageDocs, usageKey("ping", 0), usageEntry{Cmd: "ping", N: 1})
|
||||
|
||||
for _, key := range []string{
|
||||
legacyCountPrefix + "ping",
|
||||
legacyCountPrefix + "wordle",
|
||||
legacyPairPrefix + "ping:1",
|
||||
legacyPairPrefix + "ping:2",
|
||||
legacyPairPrefix + "wordle:2",
|
||||
legacyUserPrefix + "1",
|
||||
legacyUserPrefix + "2",
|
||||
} {
|
||||
if _, _, err := legacyCounts.Get(ctx, key); !errors.Is(err, storage.ErrNotFound) {
|
||||
t.Fatalf("legacy key %s should be deleted, got err=%v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
sys := systemstate.New(systemColl)
|
||||
rec, ok, err := sys.Get(ctx, usageMigrationKey)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("migration marker ok=%v err=%v", ok, err)
|
||||
}
|
||||
if rec.Status != "done" || rec.Count != 4 {
|
||||
t.Fatalf("migration marker = %+v, want done count 4", rec)
|
||||
}
|
||||
|
||||
if err := legacyCounts.Put(ctx, legacyCountPrefix+"coin", legacyCountEntry{N: 99}); err != nil {
|
||||
t.Fatalf("legacy count after marker: %v", err)
|
||||
}
|
||||
if err := InitStore(ctx, statsColl, systemColl); err != nil {
|
||||
t.Fatalf("InitStore second run: %v", err)
|
||||
}
|
||||
if _, _, err := usageDocs.Get(ctx, usageKey("coin", 0)); !errors.Is(err, storage.ErrNotFound) {
|
||||
t.Fatalf("second InitStore should skip migration after marker, got err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertUsageEntry(t *testing.T, docs storage.DocStore[usageEntry], key string, want usageEntry) {
|
||||
t.Helper()
|
||||
got, _, err := docs.Get(context.Background(), key)
|
||||
if err != nil {
|
||||
t.Fatalf("usage entry %s: %v", key, err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("usage entry %s = %+v, want %+v", key, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
package stats
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
||||
|
||||
"github.com/tiennm99/miti99bot/internal/storage"
|
||||
)
|
||||
|
||||
func bsonField(key string, value any) bson.E {
|
||||
return bson.E{Key: key, Value: value}
|
||||
}
|
||||
|
||||
// usageEntry is the only stats payload shape. One document represents either
|
||||
// a command-level anonymous bucket or a concrete (command, Telegram user) pair.
|
||||
type usageEntry struct {
|
||||
Cmd string `json:"cmd" bson:"cmd"`
|
||||
UserID int64 `json:"uid,omitempty" bson:"uid,omitempty"`
|
||||
Username string `json:"user,omitempty" bson:"user,omitempty"`
|
||||
N int64 `json:"n" bson:"n"`
|
||||
}
|
||||
|
||||
type usageUser struct {
|
||||
ID int64
|
||||
Username string
|
||||
}
|
||||
|
||||
type usageStore interface {
|
||||
Increment(ctx context.Context, cmd string, user usageUser, hasUser bool) error
|
||||
TopCommands(ctx context.Context, limit int) ([]row, error)
|
||||
TopUsers(ctx context.Context, limit int) ([]row, error)
|
||||
CommandsByUser(ctx context.Context, username string, limit int) ([]row, bool, error)
|
||||
UsersByCommand(ctx context.Context, cmd string, limit int) ([]row, error)
|
||||
}
|
||||
|
||||
func newUsageStore(coll storage.Collection) usageStore {
|
||||
if mongoColl, ok := storage.MongoCollection(coll); ok {
|
||||
return &mongoUsageStore{coll: mongoColl}
|
||||
}
|
||||
return &docUsageStore{docs: storage.Typed[usageEntry](coll)}
|
||||
}
|
||||
|
||||
func usageKey(cmd string, userID int64) string {
|
||||
if userID == 0 {
|
||||
return cmd
|
||||
}
|
||||
return cmd + ":" + strconv.FormatInt(userID, 10)
|
||||
}
|
||||
|
||||
type docUsageStore struct {
|
||||
mu sync.Mutex
|
||||
docs storage.DocStore[usageEntry]
|
||||
}
|
||||
|
||||
func (s *docUsageStore) Increment(ctx context.Context, cmd string, user usageUser, hasUser bool) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
userID := int64(0)
|
||||
if hasUser {
|
||||
userID = user.ID
|
||||
}
|
||||
key := usageKey(cmd, userID)
|
||||
entry, _, err := s.docs.Get(ctx, key)
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrNotFound):
|
||||
entry = usageEntry{}
|
||||
case err != nil:
|
||||
return fmt.Errorf("stats get %s: %w", key, err)
|
||||
}
|
||||
|
||||
entry.Cmd = cmd
|
||||
entry.N++
|
||||
if hasUser {
|
||||
entry.UserID = user.ID
|
||||
entry.Username = user.Username
|
||||
} else {
|
||||
entry.UserID = 0
|
||||
entry.Username = ""
|
||||
}
|
||||
if err := s.docs.Put(ctx, key, entry); err != nil {
|
||||
return fmt.Errorf("stats put %s: %w", key, err)
|
||||
}
|
||||
if hasUser {
|
||||
return s.refreshUsernameLocked(ctx, user)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *docUsageStore) refreshUsernameLocked(ctx context.Context, user usageUser) error {
|
||||
entries, err := s.loadEntriesLocked(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.UserID != user.ID || e.Username == user.Username {
|
||||
continue
|
||||
}
|
||||
e.Username = user.Username
|
||||
if err := s.docs.Put(ctx, usageKey(e.Cmd, e.UserID), e); err != nil {
|
||||
return fmt.Errorf("stats refresh username %d: %w", user.ID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *docUsageStore) TopCommands(ctx context.Context, limit int) ([]row, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
entries, err := s.loadEntriesLocked(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
totals := make(map[string]int64)
|
||||
for _, e := range entries {
|
||||
totals[e.Cmd] += e.N
|
||||
}
|
||||
rows := make([]row, 0, len(totals))
|
||||
for cmd, n := range totals {
|
||||
rows = append(rows, row{display: "/" + cmd, n: n})
|
||||
}
|
||||
sortRows(rows)
|
||||
return limitRows(rows, limit), nil
|
||||
}
|
||||
|
||||
func (s *docUsageStore) TopUsers(ctx context.Context, limit int) ([]row, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
entries, err := s.loadEntriesLocked(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
type total struct {
|
||||
username string
|
||||
n int64
|
||||
}
|
||||
totals := make(map[int64]total)
|
||||
for _, e := range entries {
|
||||
if e.UserID == 0 || e.Username == "" {
|
||||
continue
|
||||
}
|
||||
t := totals[e.UserID]
|
||||
t.username = e.Username
|
||||
t.n += e.N
|
||||
totals[e.UserID] = t
|
||||
}
|
||||
rows := make([]row, 0, len(totals))
|
||||
for _, t := range totals {
|
||||
rows = append(rows, row{display: "@" + t.username, n: t.n})
|
||||
}
|
||||
sortRows(rows)
|
||||
return limitRows(rows, limit), nil
|
||||
}
|
||||
|
||||
func (s *docUsageStore) CommandsByUser(ctx context.Context, username string, limit int) ([]row, bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
entries, err := s.loadEntriesLocked(ctx)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
var (
|
||||
userID int64
|
||||
found bool
|
||||
)
|
||||
for _, e := range entries {
|
||||
if e.UserID != 0 && e.Username == username {
|
||||
userID = e.UserID
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
rows := make([]row, 0)
|
||||
for _, e := range entries {
|
||||
if e.UserID == userID {
|
||||
rows = append(rows, row{display: "/" + e.Cmd, n: e.N})
|
||||
}
|
||||
}
|
||||
sortRows(rows)
|
||||
return limitRows(rows, limit), true, nil
|
||||
}
|
||||
|
||||
func (s *docUsageStore) UsersByCommand(ctx context.Context, cmd string, limit int) ([]row, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
entries, err := s.loadEntriesLocked(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows := make([]row, 0)
|
||||
for _, e := range entries {
|
||||
if e.Cmd == cmd && e.UserID != 0 && e.Username != "" {
|
||||
rows = append(rows, row{display: "@" + e.Username, n: e.N})
|
||||
}
|
||||
}
|
||||
sortRows(rows)
|
||||
return limitRows(rows, limit), nil
|
||||
}
|
||||
|
||||
func (s *docUsageStore) loadEntriesLocked(ctx context.Context) ([]usageEntry, error) {
|
||||
keys, err := s.docs.List(ctx, "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stats list: %w", err)
|
||||
}
|
||||
entries := make([]usageEntry, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
entry, _, err := s.docs.Get(ctx, key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stats get %s: %w", key, err)
|
||||
}
|
||||
if entry.Cmd == "" {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
type mongoUsageStore struct {
|
||||
coll *mongo.Collection
|
||||
}
|
||||
|
||||
func (s *mongoUsageStore) Increment(ctx context.Context, cmd string, user usageUser, hasUser bool) error {
|
||||
userID := int64(0)
|
||||
if hasUser {
|
||||
userID = user.ID
|
||||
}
|
||||
|
||||
set := bson.M{"cmd": cmd}
|
||||
update := bson.M{
|
||||
"$set": set,
|
||||
"$inc": bson.M{"n": int64(1), "version": int64(1)},
|
||||
"$currentDate": bson.M{"updatedAt": true},
|
||||
}
|
||||
if hasUser {
|
||||
set["uid"] = user.ID
|
||||
set["user"] = user.Username
|
||||
} else {
|
||||
update["$unset"] = bson.M{"uid": "", "user": ""}
|
||||
}
|
||||
|
||||
key := usageKey(cmd, userID)
|
||||
if _, err := s.coll.UpdateOne(ctx,
|
||||
bson.M{"_id": key},
|
||||
update,
|
||||
options.UpdateOne().SetUpsert(true),
|
||||
); err != nil {
|
||||
return fmt.Errorf("mongo stats increment %s: %w", key, err)
|
||||
}
|
||||
if !hasUser {
|
||||
return nil
|
||||
}
|
||||
if _, err := s.coll.UpdateMany(ctx,
|
||||
bson.M{"uid": user.ID, "user": bson.M{"$ne": user.Username}},
|
||||
bson.M{
|
||||
"$set": bson.M{"user": user.Username},
|
||||
"$inc": bson.M{"version": int64(1)},
|
||||
"$currentDate": bson.M{"updatedAt": true},
|
||||
},
|
||||
); err != nil {
|
||||
return fmt.Errorf("mongo stats refresh username %d: %w", user.ID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *mongoUsageStore) TopCommands(ctx context.Context, limit int) ([]row, error) {
|
||||
pipeline := mongo.Pipeline{
|
||||
bson.D{bsonField("$match", bson.D{bsonField("cmd", bson.D{bsonField("$type", "string")})})},
|
||||
bson.D{bsonField("$group", bson.D{bsonField("_id", "$cmd"), bsonField("n", bson.D{bsonField("$sum", "$n")})})},
|
||||
bson.D{bsonField("$sort", bson.D{bsonField("n", -1), bsonField("_id", 1)})},
|
||||
}
|
||||
pipeline = withLimit(pipeline, limit)
|
||||
|
||||
cur, err := s.coll.Aggregate(ctx, pipeline)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mongo stats top commands: %w", err)
|
||||
}
|
||||
defer func() { _ = cur.Close(ctx) }()
|
||||
|
||||
var rows []row
|
||||
for cur.Next(ctx) {
|
||||
var doc struct {
|
||||
Cmd string `bson:"_id"`
|
||||
N int64 `bson:"n"`
|
||||
}
|
||||
if err := cur.Decode(&doc); err != nil {
|
||||
return nil, fmt.Errorf("mongo stats top commands decode: %w", err)
|
||||
}
|
||||
rows = append(rows, row{display: "/" + doc.Cmd, n: doc.N})
|
||||
}
|
||||
if err := cur.Err(); err != nil {
|
||||
return nil, fmt.Errorf("mongo stats top commands cursor: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (s *mongoUsageStore) TopUsers(ctx context.Context, limit int) ([]row, error) {
|
||||
pipeline := mongo.Pipeline{
|
||||
bson.D{bsonField("$match", bson.D{
|
||||
bsonField("uid", bson.D{bsonField("$gt", int64(0))}),
|
||||
bsonField("user", bson.D{bsonField("$type", "string"), bsonField("$ne", "")}),
|
||||
})},
|
||||
bson.D{bsonField("$sort", bson.D{bsonField("updatedAt", -1)})},
|
||||
bson.D{bsonField("$group", bson.D{
|
||||
bsonField("_id", "$uid"),
|
||||
bsonField("user", bson.D{bsonField("$first", "$user")}),
|
||||
bsonField("n", bson.D{bsonField("$sum", "$n")}),
|
||||
})},
|
||||
bson.D{bsonField("$sort", bson.D{bsonField("n", -1), bsonField("user", 1)})},
|
||||
}
|
||||
pipeline = withLimit(pipeline, limit)
|
||||
|
||||
cur, err := s.coll.Aggregate(ctx, pipeline)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mongo stats top users: %w", err)
|
||||
}
|
||||
defer func() { _ = cur.Close(ctx) }()
|
||||
|
||||
var rows []row
|
||||
for cur.Next(ctx) {
|
||||
var doc struct {
|
||||
User string `bson:"user"`
|
||||
N int64 `bson:"n"`
|
||||
}
|
||||
if err := cur.Decode(&doc); err != nil {
|
||||
return nil, fmt.Errorf("mongo stats top users decode: %w", err)
|
||||
}
|
||||
rows = append(rows, row{display: "@" + doc.User, n: doc.N})
|
||||
}
|
||||
if err := cur.Err(); err != nil {
|
||||
return nil, fmt.Errorf("mongo stats top users cursor: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (s *mongoUsageStore) CommandsByUser(ctx context.Context, username string, limit int) ([]row, bool, error) {
|
||||
userID, found, err := s.userIDByUsername(ctx, username)
|
||||
if err != nil || !found {
|
||||
return nil, found, err
|
||||
}
|
||||
|
||||
opts := options.Find().
|
||||
SetProjection(bson.M{"cmd": 1, "n": 1}).
|
||||
SetSort(bson.D{bsonField("n", -1), bsonField("cmd", 1)})
|
||||
if limit > 0 {
|
||||
opts.SetLimit(int64(limit))
|
||||
}
|
||||
cur, err := s.coll.Find(ctx, bson.M{
|
||||
"uid": userID,
|
||||
"cmd": bson.M{"$type": "string"},
|
||||
}, opts)
|
||||
if err != nil {
|
||||
return nil, true, fmt.Errorf("mongo stats commands by user %s: %w", username, err)
|
||||
}
|
||||
defer func() { _ = cur.Close(ctx) }()
|
||||
|
||||
var rows []row
|
||||
for cur.Next(ctx) {
|
||||
var doc usageEntry
|
||||
if err := cur.Decode(&doc); err != nil {
|
||||
return nil, true, fmt.Errorf("mongo stats commands by user decode: %w", err)
|
||||
}
|
||||
rows = append(rows, row{display: "/" + doc.Cmd, n: doc.N})
|
||||
}
|
||||
if err := cur.Err(); err != nil {
|
||||
return nil, true, fmt.Errorf("mongo stats commands by user cursor: %w", err)
|
||||
}
|
||||
return rows, true, nil
|
||||
}
|
||||
|
||||
func (s *mongoUsageStore) UsersByCommand(ctx context.Context, cmd string, limit int) ([]row, error) {
|
||||
opts := options.Find().
|
||||
SetProjection(bson.M{"user": 1, "n": 1}).
|
||||
SetSort(bson.D{bsonField("n", -1), bsonField("user", 1)})
|
||||
if limit > 0 {
|
||||
opts.SetLimit(int64(limit))
|
||||
}
|
||||
cur, err := s.coll.Find(ctx, bson.M{
|
||||
"cmd": cmd,
|
||||
"uid": bson.M{"$gt": int64(0)},
|
||||
"user": bson.M{"$type": "string", "$ne": ""},
|
||||
}, opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mongo stats users by command %s: %w", cmd, err)
|
||||
}
|
||||
defer func() { _ = cur.Close(ctx) }()
|
||||
|
||||
var rows []row
|
||||
for cur.Next(ctx) {
|
||||
var doc usageEntry
|
||||
if err := cur.Decode(&doc); err != nil {
|
||||
return nil, fmt.Errorf("mongo stats users by command decode: %w", err)
|
||||
}
|
||||
rows = append(rows, row{display: "@" + doc.Username, n: doc.N})
|
||||
}
|
||||
if err := cur.Err(); err != nil {
|
||||
return nil, fmt.Errorf("mongo stats users by command cursor: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (s *mongoUsageStore) userIDByUsername(ctx context.Context, username string) (int64, bool, error) {
|
||||
var doc struct {
|
||||
UserID int64 `bson:"uid"`
|
||||
}
|
||||
err := s.coll.FindOne(ctx,
|
||||
bson.M{
|
||||
"uid": bson.M{"$gt": int64(0)},
|
||||
"user": username,
|
||||
},
|
||||
options.FindOne().
|
||||
SetProjection(bson.M{"uid": 1}).
|
||||
SetSort(bson.D{bsonField("updatedAt", -1)}),
|
||||
).Decode(&doc)
|
||||
if err != nil {
|
||||
if errors.Is(err, mongo.ErrNoDocuments) {
|
||||
return 0, false, nil
|
||||
}
|
||||
return 0, false, fmt.Errorf("mongo stats find user %s: %w", username, err)
|
||||
}
|
||||
return doc.UserID, true, nil
|
||||
}
|
||||
|
||||
func withLimit(pipeline mongo.Pipeline, limit int) mongo.Pipeline {
|
||||
if limit <= 0 {
|
||||
return pipeline
|
||||
}
|
||||
return append(pipeline, bson.D{bsonField("$limit", int64(limit))})
|
||||
}
|
||||
|
||||
func limitRows(rows []row, limit int) []row {
|
||||
if limit <= 0 || len(rows) <= limit {
|
||||
return rows
|
||||
}
|
||||
return rows[:limit]
|
||||
}
|
||||
+13
-184
@@ -4,9 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
@@ -89,225 +87,56 @@ func parseSubargs(update *models.Update) string {
|
||||
}
|
||||
|
||||
func viewTopCommands(ctx context.Context, c *counter) string {
|
||||
keys, err := c.counts.List(ctx, countPrefix)
|
||||
rows, err := c.store.TopCommands(ctx, topK)
|
||||
if err != nil {
|
||||
log.Error("stats: store list failed", "err", err)
|
||||
log.Error("stats: top commands failed", "err", err)
|
||||
return "Could not load stats. Try again later."
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
return "No command stats yet."
|
||||
}
|
||||
rows := fanOutCountRows(ctx, c, keys, func(k string) string {
|
||||
return "/" + strings.TrimPrefix(k, countPrefix)
|
||||
})
|
||||
if len(rows) == 0 {
|
||||
return "No command stats yet."
|
||||
}
|
||||
sortRows(rows)
|
||||
return renderTopN("Command usage:", rows, topK)
|
||||
}
|
||||
|
||||
func viewTopUsers(ctx context.Context, c *counter) string {
|
||||
users := loadUserRowsWithID(ctx, c)
|
||||
rows := make([]row, 0, len(users))
|
||||
for _, u := range users {
|
||||
if u.Username == "" {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, row{display: "@" + u.Username, n: u.N})
|
||||
rows, err := c.store.TopUsers(ctx, topK)
|
||||
if err != nil {
|
||||
log.Error("stats: top users failed", "err", err)
|
||||
return "Could not load stats. Try again later."
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return "No user stats yet."
|
||||
}
|
||||
sortRows(rows)
|
||||
return renderTopN("Top users:", rows, topK)
|
||||
}
|
||||
|
||||
func viewUserCommands(ctx context.Context, c *counter, username string) string {
|
||||
users := loadUserRowsWithID(ctx, c)
|
||||
// Telegram allows username reuse after one user changes theirs, so two
|
||||
// distinct user IDs may briefly share a username. First match wins; map
|
||||
// iteration order is nondeterministic, so the choice is not stable across
|
||||
// renders. Accepted limitation — disambiguating would need historical
|
||||
// (timestamp, ID) data we don't store.
|
||||
var (
|
||||
foundID int64
|
||||
ok bool
|
||||
)
|
||||
for id, u := range users {
|
||||
if u.Username == username {
|
||||
foundID = id
|
||||
ok = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Sprintf("User @%s not found.", username)
|
||||
}
|
||||
|
||||
keys, err := c.counts.List(ctx, pairPrefix)
|
||||
rows, found, err := c.store.CommandsByUser(ctx, username, topK)
|
||||
if err != nil {
|
||||
log.Error("stats: store list failed", "err", err)
|
||||
log.Error("stats: commands by user failed", "username", username, "err", err)
|
||||
return "Could not load stats. Try again later."
|
||||
}
|
||||
// Leading colon disambiguates: ":2" does not match a key ending in "12"
|
||||
// because IDs are bare decimal integers with no internal punctuation.
|
||||
suffix := ":" + strconv.FormatInt(foundID, 10)
|
||||
var matching []string
|
||||
for _, k := range keys {
|
||||
if strings.HasSuffix(k, suffix) {
|
||||
matching = append(matching, k)
|
||||
}
|
||||
if !found {
|
||||
return fmt.Sprintf("User @%s not found.", username)
|
||||
}
|
||||
if len(matching) == 0 {
|
||||
return fmt.Sprintf("No commands recorded for @%s.", username)
|
||||
}
|
||||
|
||||
rows := fanOutCountRows(ctx, c, matching, func(k string) string {
|
||||
// k looks like "pair:<cmd>:<id>" — drop the prefix and trailing :<id>.
|
||||
rest := strings.TrimPrefix(k, pairPrefix)
|
||||
idx := strings.LastIndexByte(rest, ':')
|
||||
if idx < 0 {
|
||||
return "/" + rest
|
||||
}
|
||||
return "/" + rest[:idx]
|
||||
})
|
||||
if len(rows) == 0 {
|
||||
return fmt.Sprintf("No commands recorded for @%s.", username)
|
||||
}
|
||||
sortRows(rows)
|
||||
return renderTopN(fmt.Sprintf("Commands by @%s:", username), rows, topK)
|
||||
}
|
||||
|
||||
func viewCmdUsers(ctx context.Context, c *counter, cmd string) string {
|
||||
prefix := pairPrefix + cmd + ":"
|
||||
keys, err := c.counts.List(ctx, prefix)
|
||||
rows, err := c.store.UsersByCommand(ctx, cmd, topK)
|
||||
if err != nil {
|
||||
log.Error("stats: store list failed", "err", err)
|
||||
log.Error("stats: users by command failed", "command", cmd, "err", err)
|
||||
return "Could not load stats. Try again later."
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
if len(rows) == 0 {
|
||||
return fmt.Sprintf("Command /%s has no users yet.", cmd)
|
||||
}
|
||||
|
||||
type result struct {
|
||||
r row
|
||||
ok bool
|
||||
}
|
||||
results := make([]result, len(keys))
|
||||
var wg sync.WaitGroup
|
||||
for i, k := range keys {
|
||||
wg.Add(1)
|
||||
go func(i int, k string) {
|
||||
defer wg.Done()
|
||||
ce, _, err := c.counts.Get(ctx, k)
|
||||
if err != nil {
|
||||
log.Error("stats: store get failed", "key", k, "err", err)
|
||||
return
|
||||
}
|
||||
idStr := strings.TrimPrefix(k, prefix)
|
||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
ue, _, err := c.users.Get(ctx, userKey(id))
|
||||
if err != nil || ue.Username == "" {
|
||||
return
|
||||
}
|
||||
results[i] = result{r: row{display: "@" + ue.Username, n: ce.N}, ok: true}
|
||||
}(i, k)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
rows := make([]row, 0, len(results))
|
||||
for _, r := range results {
|
||||
if r.ok {
|
||||
rows = append(rows, r.r)
|
||||
}
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return fmt.Sprintf("Command /%s has no named users yet.", cmd)
|
||||
}
|
||||
sortRows(rows)
|
||||
return renderTopN(fmt.Sprintf("Users of /%s:", cmd), rows, topK)
|
||||
}
|
||||
|
||||
// fanOutCountRows runs Get on each key in parallel. displayFor maps the
|
||||
// raw sort key to the human-readable label that gets rendered. Missing /
|
||||
// errored keys are skipped (logged at error level).
|
||||
func fanOutCountRows(ctx context.Context, c *counter, keys []string, displayFor func(k string) string) []row {
|
||||
type result struct {
|
||||
r row
|
||||
ok bool
|
||||
}
|
||||
results := make([]result, len(keys))
|
||||
var wg sync.WaitGroup
|
||||
for i, k := range keys {
|
||||
wg.Add(1)
|
||||
go func(i int, k string) {
|
||||
defer wg.Done()
|
||||
entry, _, err := c.counts.Get(ctx, k)
|
||||
if err != nil {
|
||||
log.Error("stats: store get failed during render", "key", k, "err", err)
|
||||
return
|
||||
}
|
||||
results[i] = result{r: row{display: displayFor(k), n: entry.N}, ok: true}
|
||||
}(i, k)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
rows := make([]row, 0, len(results))
|
||||
for _, r := range results {
|
||||
if r.ok {
|
||||
rows = append(rows, r.r)
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// loadUserRowsWithID returns userID → userEntry for every user:* row. Used by
|
||||
// viewTopUsers (renders) and viewUserCommands (resolves a username to its ID).
|
||||
func loadUserRowsWithID(ctx context.Context, c *counter) map[int64]userEntry {
|
||||
keys, err := c.users.List(ctx, userPrefix)
|
||||
if err != nil {
|
||||
log.Error("stats: store list failed", "err", err)
|
||||
return nil
|
||||
}
|
||||
type pair struct {
|
||||
id int64
|
||||
entry userEntry
|
||||
ok bool
|
||||
}
|
||||
results := make([]pair, len(keys))
|
||||
var wg sync.WaitGroup
|
||||
for i, k := range keys {
|
||||
wg.Add(1)
|
||||
go func(i int, k string) {
|
||||
defer wg.Done()
|
||||
idStr := strings.TrimPrefix(k, userPrefix)
|
||||
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
entry, _, err := c.users.Get(ctx, k)
|
||||
if err != nil {
|
||||
log.Error("stats: store get failed", "key", k, "err", err)
|
||||
return
|
||||
}
|
||||
results[i] = pair{id: id, entry: entry, ok: true}
|
||||
}(i, k)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
out := make(map[int64]userEntry, len(results))
|
||||
for _, p := range results {
|
||||
if p.ok {
|
||||
out[p.id] = p.entry
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sortRows(rows []row) {
|
||||
sort.Slice(rows, func(i, j int) bool {
|
||||
if rows[i].n != rows[j].n {
|
||||
|
||||
@@ -27,3 +27,14 @@ func (p *MongoProvider) Collection(moduleName string) Collection {
|
||||
}
|
||||
return mongoCollection{coll: p.db.Collection(moduleName), module: moduleName}
|
||||
}
|
||||
|
||||
// MongoCollection returns the native MongoDB collection behind c when the
|
||||
// active backend is MongoDB. Modules with query-shaped data can opt into Mongo
|
||||
// operators while still keeping the memory backend for tests/local runs.
|
||||
func MongoCollection(c Collection) (*mongo.Collection, bool) {
|
||||
h, ok := c.(mongoCollection)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
return h.coll, true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package systemstate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/tiennm99/miti99bot/internal/storage"
|
||||
)
|
||||
|
||||
// CollectionName is the app-level collection for startup tasks and other
|
||||
// process metadata that does not belong to a feature module.
|
||||
const CollectionName = "system"
|
||||
|
||||
// Record is intentionally small and generic. Stable keys carry the meaning;
|
||||
// optional fields let startup tasks record counts and completion state.
|
||||
type Record struct {
|
||||
Kind string `json:"kind" bson:"kind"`
|
||||
Name string `json:"name" bson:"name"`
|
||||
Status string `json:"status,omitempty" bson:"status,omitempty"`
|
||||
Count int64 `json:"count,omitempty" bson:"count,omitempty"`
|
||||
CompletedAt int64 `json:"completed_at,omitempty" bson:"completed_at,omitempty"`
|
||||
UpdatedAt int64 `json:"updated_at" bson:"updated_at"`
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
docs storage.DocStore[Record]
|
||||
}
|
||||
|
||||
func New(coll storage.Collection) Store {
|
||||
return Store{docs: storage.Typed[Record](coll)}
|
||||
}
|
||||
|
||||
func (s Store) Get(ctx context.Context, key string) (Record, bool, error) {
|
||||
rec, _, err := s.docs.Get(ctx, key)
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrNotFound) {
|
||||
return Record{}, false, nil
|
||||
}
|
||||
return Record{}, false, err
|
||||
}
|
||||
return rec, true, nil
|
||||
}
|
||||
|
||||
func (s Store) Put(ctx context.Context, key string, rec Record) error {
|
||||
return s.docs.Put(ctx, key, rec)
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
---
|
||||
type: research
|
||||
topic: mongodb-stats-design
|
||||
created_at: 2026-07-01T03:00:06Z
|
||||
---
|
||||
|
||||
# Research Report: MongoDB Stats Design
|
||||
|
||||
## Executive Summary
|
||||
|
||||
For this bot's `/stats` feature, best primary model is one aggregate document per
|
||||
`(command, user)` pair, plus one anonymous command bucket when sender has no
|
||||
username. This keeps documents bounded, uses MongoDB atomic `$inc`, and makes all
|
||||
current views queryable without dynamic object keys.
|
||||
|
||||
Do not store stats as "one user document with commands map" or "one command
|
||||
document with users map" as primary shape. Those designs make one query easy and
|
||||
the opposite query expensive, create hot growing documents, and push us toward
|
||||
dynamic field names. Pair docs are boring and better.
|
||||
|
||||
Recommended document:
|
||||
|
||||
```js
|
||||
{ _id: "ping:42", cmd: "ping", uid: 42, user: "alice", n: 3 }
|
||||
{ _id: "ping", cmd: "ping", n: 2 } // no public username
|
||||
```
|
||||
|
||||
## Methodology
|
||||
|
||||
- Sources consulted: MongoDB official docs, Go driver docs, local codebase.
|
||||
- Date: 2026-07-01.
|
||||
- Key terms: MongoDB schema design, unbounded arrays, `$inc`, atomicity,
|
||||
aggregation `$group`, `$sort`, compound indexes, ESR guideline.
|
||||
- Scope: stats module only. Bot persistence remains one collection per module.
|
||||
|
||||
## Codebase Context
|
||||
|
||||
- Repo: Go Telegram bot.
|
||||
- Storage: `internal/storage` has one MongoDB collection per module.
|
||||
- Current stats: `count:*`, `user:*`, `pair:*` key families in one collection.
|
||||
- Current views:
|
||||
- `/stats`: top commands.
|
||||
- `/stats users`: top users.
|
||||
- `/stats user <name>`: commands by user.
|
||||
- `/stats cmd <name>`: users by command.
|
||||
- Mongo docs already store flattened BSON fields, so stats fields can be queried.
|
||||
|
||||
## Key Findings
|
||||
|
||||
### 1. Avoid Growing Nested User/Command Maps
|
||||
|
||||
MongoDB docs warn against unbounded arrays because growing documents hurt
|
||||
performance and risk document size limits. The same practical concern applies to
|
||||
ever-growing embedded maps like `commands` under a user or `users` under a
|
||||
command: one document grows forever and becomes a write hotspot.
|
||||
|
||||
Source: MongoDB "Avoid Unbounded Arrays":
|
||||
https://www.mongodb.com/docs/manual/data-modeling/design-antipatterns/unbounded-arrays/
|
||||
|
||||
### 2. Use Atomic `$inc` For Counts
|
||||
|
||||
MongoDB `$inc` creates missing numeric fields, increments by the given amount,
|
||||
and is atomic within one document. This fits per-pair counter documents exactly.
|
||||
|
||||
Source: MongoDB `$inc` operator:
|
||||
https://www.mongodb.com/docs/manual/reference/operator/update/inc/
|
||||
|
||||
MongoDB write operations are atomic at single-document level. Multi-document
|
||||
updates are not atomic as a whole, so each counter increment should update one
|
||||
aggregate document.
|
||||
|
||||
Source: MongoDB atomicity:
|
||||
https://www.mongodb.com/docs/manual/core/write-operations-atomicity/
|
||||
|
||||
### 3. Use Aggregation For Cross-Dimension Totals
|
||||
|
||||
MongoDB `$group` combines documents by a group key. That fits:
|
||||
|
||||
```js
|
||||
// top commands
|
||||
[{ $group: { _id: "$cmd", n: { $sum: "$n" } } }]
|
||||
|
||||
// top users
|
||||
[{ $match: { uid: { $gt: 0 } } },
|
||||
{ $group: { _id: "$uid", user: { $first: "$user" }, n: { $sum: "$n" } } }]
|
||||
```
|
||||
|
||||
Source: MongoDB `$group`:
|
||||
https://www.mongodb.com/docs/manual/reference/operator/aggregation/group/
|
||||
|
||||
Use `$sort` then `$limit` for top K. MongoDB can coalesce adjacent sort/limit so
|
||||
only top N must be kept in memory.
|
||||
|
||||
Source: MongoDB `$sort` optimization:
|
||||
https://www.mongodb.com/docs/manual/reference/operator/aggregation/sort/
|
||||
|
||||
### 4. Index From Query Shape, Not Guesswork
|
||||
|
||||
MongoDB aggregation can use indexes, especially early `$match` and `$sort`
|
||||
stages. The ESR guideline says equality fields first, then sort, then range for
|
||||
compound indexes.
|
||||
|
||||
Sources:
|
||||
- Aggregation pipeline optimization:
|
||||
https://www.mongodb.com/docs/manual/core/aggregation-pipeline-optimization/
|
||||
- ESR guideline:
|
||||
https://www.mongodb.com/docs/manual/tutorial/equality-sort-range-guideline/
|
||||
- Indexing strategies:
|
||||
https://www.mongodb.com/docs/manual/applications/indexes/
|
||||
|
||||
For this module, start with no extra indexes or a tiny set. Traffic likely low.
|
||||
Add indexes once data grows or `/stats` gets slow.
|
||||
|
||||
Useful indexes if needed:
|
||||
|
||||
```js
|
||||
db.stats.createIndex({ cmd: 1, n: -1 }) // /stats cmd <name>
|
||||
db.stats.createIndex({ uid: 1, n: -1 }) // /stats user <name>
|
||||
db.stats.createIndex({ user: 1, updatedAt: -1 }) // resolve username
|
||||
```
|
||||
|
||||
Do not add many indexes early. Each index costs memory/disk and write work.
|
||||
|
||||
## Comparative Analysis
|
||||
|
||||
| Shape | Example | Good | Bad | Verdict |
|
||||
|---|---|---|---|---|
|
||||
| User-rooted | `{ uid, user, commands: { ping: 3 } }` | `/stats user` easy | `/stats cmd` scans users, dynamic keys, growing doc | Reject |
|
||||
| Command-rooted | `{ cmd, users: { "42": { n: 3 } } }` | `/stats cmd` easy | `/stats user` scans commands, hot command docs, dynamic keys | Reject |
|
||||
| Pair-rooted | `{ cmd, uid, user, n }` | All 4 views queryable, bounded docs, atomic `$inc` | Aggregation needed for totals | Recommend |
|
||||
| Event log | `{ cmd, uid, user, at }` per invocation | Full history | Too much data, needs rollups | Overkill |
|
||||
|
||||
## Implementation Recommendations
|
||||
|
||||
### Primary Model
|
||||
|
||||
Use one document per aggregate:
|
||||
|
||||
```go
|
||||
type usageEntry struct {
|
||||
Cmd string `bson:"cmd" json:"cmd"`
|
||||
UserID int64 `bson:"uid,omitempty" json:"uid,omitempty"`
|
||||
Username string `bson:"user,omitempty" json:"user,omitempty"`
|
||||
N int64 `bson:"n" json:"n"`
|
||||
}
|
||||
```
|
||||
|
||||
Keys:
|
||||
|
||||
```text
|
||||
<cmd> anonymous/no-public-username bucket
|
||||
<cmd>:<uid> named user pair bucket
|
||||
```
|
||||
|
||||
### Write Path
|
||||
|
||||
Use MongoDB `UpdateOne` with `upsert: true`:
|
||||
|
||||
```js
|
||||
db.stats.updateOne(
|
||||
{ _id: "ping:42" },
|
||||
{
|
||||
$set: { cmd: "ping", uid: 42, user: "alice" },
|
||||
$inc: { n: 1 },
|
||||
$currentDate: { updatedAt: true }
|
||||
},
|
||||
{ upsert: true }
|
||||
)
|
||||
```
|
||||
|
||||
This avoids read-modify-write races in the current generic `DocStore` loop.
|
||||
|
||||
### Read Path
|
||||
|
||||
Use Mongo queries/aggregation when backend is MongoDB. Keep in-memory fallback
|
||||
for tests and no-database local run.
|
||||
|
||||
Queries:
|
||||
|
||||
```js
|
||||
// /stats
|
||||
db.stats.aggregate([
|
||||
{ $group: { _id: "$cmd", n: { $sum: "$n" } } },
|
||||
{ $sort: { n: -1, _id: 1 } },
|
||||
{ $limit: 20 }
|
||||
])
|
||||
|
||||
// /stats users
|
||||
db.stats.aggregate([
|
||||
{ $match: { uid: { $gt: 0 }, user: { $type: "string", $ne: "" } } },
|
||||
{ $group: { _id: "$uid", user: { $first: "$user" }, n: { $sum: "$n" } } },
|
||||
{ $sort: { n: -1, user: 1 } },
|
||||
{ $limit: 20 }
|
||||
])
|
||||
|
||||
// /stats user alice
|
||||
db.stats.find({ uid: 42 }).sort({ n: -1, cmd: 1 }).limit(20)
|
||||
|
||||
// /stats cmd ping
|
||||
db.stats.find({ cmd: "ping", uid: { $gt: 0 } }).sort({ n: -1, user: 1 }).limit(20)
|
||||
```
|
||||
|
||||
### Username Rename
|
||||
|
||||
When a user calls a command with a new username, refresh all docs for that `uid`
|
||||
with `UpdateMany({ uid }, { $set: { user } })`. It is not atomic as a whole, but
|
||||
stats are best-effort and eventual consistency is acceptable.
|
||||
|
||||
### Migration
|
||||
|
||||
Existing prod data likely in old key prefixes. Options:
|
||||
|
||||
1. No migration: simplest, stats reset after deploy.
|
||||
2. Read old + new for one release: more code, little value.
|
||||
3. One-off migration script: only if historical stats matter.
|
||||
|
||||
Recommendation: no migration unless user explicitly wants historical stats.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- Do not use dynamic fields like `commands.ping` or `users.42` as primary model.
|
||||
- Do not grow one document forever.
|
||||
- Do not use generic get-then-put for hot counters on MongoDB.
|
||||
- Do not over-index small collections.
|
||||
- Do not use username as identity. Telegram username can change; use `uid`.
|
||||
|
||||
## Decision
|
||||
|
||||
Recommended: pair-rooted aggregate docs, Mongo atomic upsert increments, Mongo
|
||||
aggregation for display views, memory fallback for tests.
|
||||
|
||||
## References
|
||||
|
||||
- MongoDB Avoid Unbounded Arrays:
|
||||
https://www.mongodb.com/docs/manual/data-modeling/design-antipatterns/unbounded-arrays/
|
||||
- MongoDB `$inc`:
|
||||
https://www.mongodb.com/docs/manual/reference/operator/update/inc/
|
||||
- MongoDB Atomicity:
|
||||
https://www.mongodb.com/docs/manual/core/write-operations-atomicity/
|
||||
- MongoDB `$group`:
|
||||
https://www.mongodb.com/docs/manual/reference/operator/aggregation/group/
|
||||
- MongoDB `$sort`:
|
||||
https://www.mongodb.com/docs/manual/reference/operator/aggregation/sort/
|
||||
- MongoDB Aggregation Optimization:
|
||||
https://www.mongodb.com/docs/manual/core/aggregation-pipeline-optimization/
|
||||
- MongoDB ESR Guideline:
|
||||
https://www.mongodb.com/docs/manual/tutorial/equality-sort-range-guideline/
|
||||
- MongoDB Indexing Strategies:
|
||||
https://www.mongodb.com/docs/manual/applications/indexes/
|
||||
- MongoDB Go Driver Update:
|
||||
https://www.mongodb.com/docs/drivers/go/current/crud/update/
|
||||
- MongoDB Go Driver Indexes:
|
||||
https://www.mongodb.com/docs/drivers/go/current/indexes/
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Confirm pair-rooted aggregate docs.
|
||||
2. Finish stats implementation.
|
||||
3. Run `go test ./internal/modules/stats ./internal/storage`.
|
||||
4. Optional later: add Mongo integration test if local Mongo available.
|
||||
|
||||
## Unresolved Questions
|
||||
|
||||
- Keep old stats by migration, or accept reset?
|
||||
- Add indexes now, or wait until data size/latency proves need?
|
||||
Reference in New Issue
Block a user