Compare commits

...

25 Commits

Author SHA1 Message Date
William Perron 2b3ff1aa04
improve instrumentation of internal notifier
6 months ago
William Perron 18165a827c
add some emojis
7 months ago
William Perron 3c4de5a8c3
enable propagation of the weekly notification spans
9 months ago
William Perron 451a113987
add root span for weekly reminder
9 months ago
William Perron 66a0fd92b7
add fly detector to otel setup
9 months ago
William Perron 96df84736b
mark some responses as ephemeral and enable cpu profiling
9 months ago
William Perron 13a65780d8
remove unused import
9 months ago
William Perron 619fa082ac
fix failing test
9 months ago
William Perron d52efdce94
fix lints and update logger
9 months ago
William Perron 8c7099796c
update sqlitexporter dep
9 months ago
William Perron a46de17b7c
refactor discord command span in its own middleware
10 months ago
William Perron 6b99919dd7
fix context TODO usage in main.go
10 months ago
William Perron d96cd1a0ee
bump system deps
10 months ago
William Perron e95f741820
bump sqliteexporter dependency and improve error handling for init tracing
10 months ago
William Perron 6404b508e8
update deps and go mod tidy
10 months ago
William Perron 3e8d49c0c0
add service name attribute and fix context for flush
10 months ago
William Perron 6b280e7b33
finish instrumenting with opentelemetry
10 months ago
William Perron cc45c55922
instrumented absences with opentelemetry
10 months ago
William Perron 27567d16a2
convert to opentelemetry part 1
10 months ago
William Perron 1646762081
wip: refactor command logging and tracing
10 months ago
William Perron 4c09153756
wip: implement embedded distributed tracing
10 months ago
William Perron 407d63d4e6
remove unused test function
11 months ago
William Perron 1097ce5fce
use table instead of view for better perf
11 months ago
William Perron 31f2813192
fix scheduled message emoji error
11 months ago
William Perron 171ef4ee98
Add `claimables` view to database
11 months ago

@ -6,7 +6,7 @@ jobs:
steps:
- uses: actions/setup-go@v3
with:
go-version: 1.19
go-version: 1.21
- uses: actions/checkout@v3
- name: "fmt"
run: test -z $(go fmt ./...)

@ -3,13 +3,13 @@ WORKDIR /app
COPY . .
RUN mkdir ./bin; go build -buildvcs=false -o ./bin ./cmd/...
FROM ubuntu:22.04 as litestream
FROM ubuntu:23.10 as litestream
WORKDIR /download
RUN apt update -y && apt install -y wget tar
RUN wget https://github.com/benbjohnson/litestream/releases/download/v0.3.9/litestream-v0.3.9-linux-amd64.tar.gz; \
tar -zxf litestream-v0.3.9-linux-amd64.tar.gz;
FROM ubuntu:22.04
FROM ubuntu:23.10
WORKDIR /themis
COPY --from=builder /app/bin/themis-server /usr/local/bin/themis-server
COPY --from=litestream /download/litestream /usr/local/bin/litestream

@ -7,7 +7,7 @@ Discord App to allow EU4 players to take claims on regions and provinces.
### Requirements
To develop:
- [Go](https://go.dev/) version 1.19 or higher installed locally
- [Go](https://go.dev/) version 1.21 or higher installed locally
- `sqlite3` installed locally (already ships by default on most OSes)
To deploy:

@ -6,11 +6,25 @@ import (
"time"
"github.com/rs/zerolog/log"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
"go.opentelemetry.io/otel/trace"
)
func (s *Store) AddAbsence(ctx context.Context, session time.Time, userId string) error {
ctx, span := tracer.Start(ctx, "add_absence", trace.WithAttributes(
semconv.DBSystemSqlite,
semconv.DBSQLTable("absences"),
semconv.DBOperation("insert"),
attribute.String("user_id", userId),
attribute.String("session_date", session.Format(time.DateOnly)),
))
defer span.End()
if session.Weekday() != time.Monday {
log.Debug().Ctx(ctx).Msg(fmt.Sprintf("%s is not a monday", session))
span.RecordError(fmt.Errorf("%s is not a monday", session))
return fmt.Errorf("not a monday")
}
@ -21,17 +35,23 @@ func (s *Store) AddAbsence(ctx context.Context, session time.Time, userId string
tx, err := s.db.Begin()
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to begin transaction")
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Commit() //nolint:errcheck
stmt, err := s.db.PrepareContext(ctx, "INSERT INTO absences (session_date, userid) VALUES (?, ?)")
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to prepare absence query")
return fmt.Errorf("failed to prepare absence query: %w", err)
}
_, err = stmt.ExecContext(ctx, session.Format(time.DateOnly), userId)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to insert absence")
return fmt.Errorf("failed to insert absence: %w", err)
}
@ -39,20 +59,34 @@ func (s *Store) AddAbsence(ctx context.Context, session time.Time, userId string
}
func (s *Store) GetAbsentees(ctx context.Context, session time.Time) ([]string, error) {
ctx, span := tracer.Start(ctx, "get_absentees", trace.WithAttributes(
semconv.DBSystemSqlite,
semconv.DBSQLTable("absences"),
semconv.DBOperation("select"),
attribute.String("session_date", session.Format(time.DateOnly)),
))
defer span.End()
log.Debug().Ctx(ctx).Time("session", session).Msg("getting list of absentees")
tx, err := s.db.Begin()
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to begin transaction")
return nil, fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Commit() //nolint:errcheck
stmt, err := s.db.PrepareContext(ctx, `SELECT userid FROM absences WHERE session_date = ?`)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to prepare query")
return nil, fmt.Errorf("failed to prepare query: %w", err)
}
rows, err := stmt.QueryContext(ctx, session.Format(time.DateOnly))
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to execute query")
return nil, fmt.Errorf("failed to execute query: %w", err)
}
@ -74,23 +108,38 @@ func (s *Store) GetAbsentees(ctx context.Context, session time.Time) ([]string,
type Schedule map[string][]string
func (s *Store) GetSchedule(ctx context.Context, from, to time.Time) (Schedule, error) {
ctx, span := tracer.Start(ctx, "get_schedule", trace.WithAttributes(
semconv.DBSystemSqlite,
semconv.DBSQLTable("absences"),
semconv.DBOperation("select"),
attribute.String("from", from.Format(time.DateOnly)),
attribute.String("to", to.Format(time.DateOnly)),
))
defer span.End()
log.Debug().Ctx(ctx).Time("from", from).Time("to", to).Msg("getting next sessions schedule")
schedule := make(Schedule)
initSchedule(schedule, from, to)
tx, err := s.db.Begin()
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to begin transaction")
return nil, fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Commit() //nolint:errcheck
stmt, err := s.db.PrepareContext(ctx, `SELECT session_date, userid FROM absences WHERE session_date BETWEEN ? AND ? ORDER BY session_date ASC`)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to prepare query")
return nil, fmt.Errorf("failed to prepare query: %w", err)
}
rows, err := stmt.QueryContext(ctx, from.Format(time.DateOnly), to.Format(time.DateOnly))
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to execute query")
return nil, fmt.Errorf("failed to execute query: %w", err)
}
@ -99,14 +148,12 @@ func (s *Store) GetSchedule(ctx context.Context, from, to time.Time) (Schedule,
var user string
err = rows.Scan(&date, &user)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to scan row")
return nil, fmt.Errorf("failed to scan row: %w", err)
}
if _, ok := schedule[date]; ok {
schedule[date] = append(schedule[date], user)
} else {
schedule[date] = []string{user}
}
}
return schedule, nil

@ -2,16 +2,20 @@ package themis
import (
"context"
"database/sql"
"fmt"
"testing"
"time"
"github.com/rs/zerolog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAddAbsence(t *testing.T) {
store, err := NewStore(fmt.Sprintf(TEST_CONN_STRING_PATTERN, "TestAddAbsence"))
db, err := sql.Open("sqlite3", fmt.Sprintf(TEST_CONN_STRING_PATTERN, "TestAddAbsence"))
require.NoError(t, err)
store, err := NewStore(db, zerolog.Nop())
require.NoError(t, err)
now := NextMonday()
@ -31,7 +35,9 @@ func TestAddAbsence(t *testing.T) {
}
func TestGetSchedule(t *testing.T) {
store, err := NewStore(fmt.Sprintf(TEST_CONN_STRING_PATTERN, "TestGetSchedule"))
db, err := sql.Open("sqlite3", fmt.Sprintf(TEST_CONN_STRING_PATTERN, "TestGetSchedule"))
require.NoError(t, err)
store, err := NewStore(db, zerolog.Nop())
require.NoError(t, err)
now := NextMonday()

@ -3,11 +3,14 @@ package themis
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"github.com/rs/zerolog/log"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
"go.opentelemetry.io/otel/trace"
)
type EventType int
@ -45,36 +48,50 @@ func EventTypeFromString(ev string) (EventType, error) {
case "ABSENT":
return EventAbsence, nil
default:
return EventType(9999), errors.New("no such event type")
return EventType(9999), fmt.Errorf("no such event type: %s", ev)
}
}
type AuditableEvent struct {
userId string
eventType EventType
timestamp time.Time
err error
}
// Audit writes to the audit table, returns nothing because it is meant to be
// used in a defered statement on functions that write to the database.
func (s *Store) Audit(ctx context.Context, ev *AuditableEvent) {
ctx, span := tracer.Start(ctx, "audit", trace.WithAttributes(
semconv.DBSystemSqlite,
semconv.DBSQLTable("audit_log"),
semconv.DBOperation("insert"),
attribute.String("user_id", ev.userId),
attribute.Stringer("event_type", ev.eventType),
))
defer span.End()
if ev.err == nil {
log.Debug().Ctx(ctx).Str("event_type", ev.eventType.String()).Str("userid", ev.userId).Msg("recording audit log")
ctx := context.Background()
tx, err := s.db.Begin()
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to start transaction")
log.Error().Ctx(ctx).Err(err).Msg("failed to start transaction")
}
defer tx.Commit() //nolint:errcheck
stmt, err := s.db.PrepareContext(ctx, "INSERT INTO audit_log (userid, event_type, ts) VALUES (?, ?, ?)")
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to prepare audit log insert")
log.Error().Ctx(ctx).Err(err).Msg("failed to prepare audit log insert")
}
if _, err := stmt.ExecContext(ctx, ev.userId, ev.eventType.String(), time.Now()); err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to insert audit log")
log.Error().Ctx(ctx).Err(err).Msg("failed to insert audit log")
}
}
@ -88,10 +105,20 @@ type AuditEvent struct {
}
func (s *Store) LastOf(ctx context.Context, t EventType) (AuditEvent, error) {
ctx, span := tracer.Start(ctx, "find_last_audit_log", trace.WithAttributes(
semconv.DBSystemSqlite,
semconv.DBSQLTable("audit_log"),
semconv.DBOperation("select"),
attribute.Stringer("event_type", t),
))
defer span.End()
log.Debug().Ctx(ctx).Str("event_type", t.String()).Msg("finding last audit log")
stmt, err := s.db.PrepareContext(ctx, `SELECT id, userid, event_type, ts FROM audit_log WHERE event_type = ? ORDER BY ts DESC LIMIT 1`)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to get last event")
return AuditEvent{}, fmt.Errorf("failed to get last event of type %s: %w", t.String(), err)
}
@ -101,15 +128,20 @@ func (s *Store) LastOf(ctx context.Context, t EventType) (AuditEvent, error) {
var rawEventType string
err = row.Scan(&ev.Id, &ev.UserId, &rawEventType, &ev.Timestamp)
if err == sql.ErrNoRows {
span.RecordError(ErrNever)
return AuditEvent{}, ErrNever
}
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to scan row")
return AuditEvent{}, fmt.Errorf("failed to scan row: %w", err)
}
ev.EventType, err = EventTypeFromString(rawEventType)
if err != nil {
return AuditEvent{}, fmt.Errorf("failed to parse event type %s: %w", rawEventType, err)
span.RecordError(err)
span.SetStatus(codes.Error, "failed to parse event type")
return AuditEvent{}, fmt.Errorf("failed to parse event type: %w", err)
}
return ev, nil

@ -34,12 +34,6 @@ const (
CLAIM_TYPE_TRADE = "trade"
)
var claimTypeToColumn = map[ClaimType]string{
CLAIM_TYPE_AREA: "area",
CLAIM_TYPE_REGION: "region",
CLAIM_TYPE_TRADE: "trade_node",
}
type Claim struct {
ID int
Player string

@ -10,6 +10,7 @@ import (
"net/url"
"os"
"os/signal"
"runtime/pprof"
"sort"
"strconv"
"strings"
@ -20,10 +21,19 @@ import (
_ "github.com/mattn/go-sqlite3"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
"go.opentelemetry.io/otel/trace"
"go.wperron.io/flydetector"
"go.wperron.io/sqliteexporter"
"go.wperron.io/themis"
"go.wperron.io/themis/correlation"
zerologcompat "go.wperron.io/themis/correlation/compat/zerolog"
)
const (
@ -33,13 +43,14 @@ const (
var (
dbFile = flag.String("db", "", "SQlite database file path.")
debug = flag.Bool("debug", false, "Set log level to DEBUG.")
cpuProfile = flag.String("cpuprofile", "", "Output file for pprof profiling.")
store *themis.Store
seq = &correlation.CryptoRandSequencer{}
gen = correlation.NewGenerator(seq)
tracer trace.Tracer
propagator = propagation.TraceContext{}
)
type Handler func(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate)
type Handler func(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) error
func main() {
log.Info().Msg("startup.")
@ -55,9 +66,20 @@ func main() {
zerolog.SetGlobalLevel(zerolog.DebugLevel)
}
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stdout})
log.Logger = log.Logger.Hook(zerologcompat.CorrelationHook{})
log.Logger = log.Logger.Hook(correlation.TraceContextHook{})
zerolog.DurationFieldUnit = time.Millisecond
if *cpuProfile != "" && os.Getenv("ENV") != "production" {
log.Info().Str("file", *cpuProfile).Msg("starting profiler")
f, err := os.Create(*cpuProfile)
if err != nil {
log.Fatal().Err(err).Msg("failed to create cpu profile output file")
}
_ = pprof.StartCPUProfile(f)
defer pprof.StopCPUProfile()
}
go func() {
if err := serve(":8080"); err != nil {
log.Error().Err(err).Msg("failed to serve requests")
@ -72,13 +94,23 @@ func main() {
connString := fmt.Sprintf(CONN_STRING_PATTERN, *dbFile)
store, err = themis.NewStore(connString)
log.Debug().Str("connection_string", connString).Msg("opening sqlite3 database")
db, err := sql.Open("sqlite3", connString)
if err != nil {
log.Fatal().Err(err).Msg("failed to open database")
}
store, err = themis.NewStore(db, log.Logger)
if err != nil {
log.Fatal().Err(err).Msg("failed to initialize database")
}
defer store.Close()
notifChan := make(chan struct{})
if err := initTracing(ctx, db); err != nil {
log.Fatal().Err(err).Msg("failed to initialize tracing")
}
notifChan := make(chan context.Context)
notifier := themis.NewNotifier(notifChan)
go notifier.Start(ctx)
@ -215,7 +247,7 @@ func main() {
},
}
handlers := map[string]Handler{
"info": func(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) {
"info": func(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) error {
uptime, err := themis.Uptime(ctx)
if err != nil {
err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
@ -225,10 +257,9 @@ func main() {
},
})
if err != nil {
log.Error().Ctx(ctx).Err(err).Msg("failed to respond to interaction")
return fmt.Errorf("failed to respond to interaction: %w", err)
}
log.Error().Ctx(ctx).Err(err).Msg("failed to get server uptime")
return
return fmt.Errorf("failed to get server uptime: %w", err)
}
claimCount, uniquePlayers, err := store.CountClaims(ctx)
@ -240,10 +271,9 @@ func main() {
},
})
if err != nil {
log.Error().Ctx(ctx).Err(err).Msg("failed to respond to interaction")
return fmt.Errorf("failed to respond to interaction: %w", err)
}
log.Error().Ctx(ctx).Err(err).Msg("failed to count claims")
return
return fmt.Errorf("failed to count claims: %w", err)
}
ev, err := store.LastOf(ctx, themis.EventFlush)
@ -257,10 +287,9 @@ func main() {
},
})
if err != nil {
log.Error().Ctx(ctx).Err(err).Msg("failed to respond to interaction")
return fmt.Errorf("failed to respond to interaction: %w", err)
}
log.Error().Ctx(ctx).Err(err).Msg("failed get last flush event")
return
return fmt.Errorf("failed get last flush event: %w", err)
}
lastFlush = "never"
} else {
@ -274,10 +303,11 @@ func main() {
},
})
if err != nil {
log.Error().Ctx(ctx).Err(err).Msg("failed to respond to interaction")
return fmt.Errorf("failed to respond to interaction: %w", err)
}
return nil
},
"list-claims": func(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) {
"list-claims": func(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) error {
claims, err := store.ListClaims(ctx)
if err != nil {
err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
@ -287,10 +317,9 @@ func main() {
},
})
if err != nil {
log.Error().Ctx(ctx).Err(err).Msg("failed to respond to interaction")
return fmt.Errorf("failed to respond to interaction: %w", err)
}
log.Error().Ctx(ctx).Err(err).Msg("failed to list claims")
return
return fmt.Errorf("failed to list claims: %w", err)
}
sb := strings.Builder{}
@ -306,14 +335,16 @@ func main() {
},
})
if err != nil {
log.Error().Ctx(ctx).Err(err).Msg("failed to respond to interaction")
return fmt.Errorf("failed to respond to interaction: %w", err)
}
return nil
},
"claim": func(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) {
"claim": func(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) error {
if i.Type == discordgo.InteractionApplicationCommandAutocomplete {
log.Debug().Ctx(ctx).Msg("command type interaction autocomplete")
// TODO(wperron) fix this
handleClaimAutocomplete(ctx, store, s, i)
return
return nil
}
opts := i.ApplicationCommandData().Options
@ -321,13 +352,14 @@ func main() {
err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Flags: discordgo.MessageFlagsEphemeral,
Content: "`claim-type` and `name` are mandatory parameters",
},
})
if err != nil {
log.Error().Ctx(ctx).Err(err).Msg("failed to respond to interaction")
return fmt.Errorf("failed to respond to interaction: %w", err)
}
return
return nil
}
claimType, err := themis.ClaimTypeFromString(opts[0].StringValue())
@ -335,14 +367,14 @@ func main() {
err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Flags: discordgo.MessageFlagsEphemeral,
Content: "You can only take claims of types `area`, `region` or `trade`",
},
})
if err != nil {
log.Error().Ctx(ctx).Err(err).Msg("failed to respond to interaction")
return fmt.Errorf("failed to respond to interaction: %w", err)
}
log.Error().Ctx(ctx).Err(err).Str("claim_type", opts[0].StringValue()).Msg("failed to parse claim")
return
return fmt.Errorf("failed to parse claim: %w", err)
}
name := opts[1].StringValue()
@ -355,6 +387,7 @@ func main() {
_, err = store.Claim(ctx, userId, player, name, claimType)
if err != nil {
// TODO(wperron) fix this error cast
conflict, ok := err.(themis.ErrConflict)
if ok {
sb := strings.Builder{}
@ -367,26 +400,27 @@ func main() {
err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Flags: discordgo.MessageFlagsEphemeral,
Content: sb.String(),
},
})
if err != nil {
log.Error().Ctx(ctx).Err(err).Msg("failed to respond to interaction")
return fmt.Errorf("failed to respond to interaction")
}
return
return nil
}
err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Flags: discordgo.MessageFlagsEphemeral,
Content: "failed to acquire claim :(",
},
})
if err != nil {
log.Error().Ctx(ctx).Err(err).Msg("failed to respond to interaction")
return fmt.Errorf("failed to respond to interaction: %w", err)
}
log.Error().Ctx(ctx).Err(err).Msg("failed to acquire claim")
return
return fmt.Errorf("failed to acquire claim: %w", err)
}
err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
@ -396,10 +430,11 @@ func main() {
},
})
if err != nil {
log.Error().Ctx(ctx).Err(err).Msg("failed to respond to interaction")
return fmt.Errorf("failed to respond to interaction: %w", err)
}
return nil
},
"describe-claim": func(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) {
"describe-claim": func(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) error {
id := i.ApplicationCommandData().Options[0]
detail, err := store.DescribeClaim(ctx, int(id.IntValue()))
if err != nil {
@ -410,10 +445,9 @@ func main() {
},
})
if err != nil {
log.Error().Ctx(ctx).Err(err).Msg("failed to respond to interaction")
return fmt.Errorf("failed to respond to interaction: %w", err)
}
log.Error().Ctx(ctx).Err(err).Msg("failed to describe claim")
return
return fmt.Errorf("failed to describe claim: %w", err)
}
sb := strings.Builder{}
@ -429,15 +463,16 @@ func main() {
},
})
if err != nil {
log.Error().Ctx(ctx).Err(err).Msg("failed to respond to interaction")
return fmt.Errorf("failed to respond to interaction: %w", err)
}
return nil
},
"delete-claim": func(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) {
"delete-claim": func(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) error {
id := i.ApplicationCommandData().Options[0]
userId := i.Member.User.ID
err := store.DeleteClaim(ctx, int(id.IntValue()), userId)
if err != nil {
msg := "Oops, something went wrong :( blame @wperron"
msg := "Oops, something went wrong :( blame <@wperron>"
if errors.Is(err, themis.ErrNoSuchClaim) {
msg = fmt.Sprintf("Claim #%d not found for %s", id.IntValue(), i.Member.Nick)
}
@ -448,34 +483,33 @@ func main() {
},
})
if err != nil {
log.Error().Ctx(ctx).Err(err).Msg("failed to respond to interaction")
return fmt.Errorf("failed to respond to interaction: %w", err)
}
log.Error().Ctx(ctx).Err(err).Msg("failed to delete claim")
return
return fmt.Errorf("failed to delete claim: %w", err)
}
err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Flags: discordgo.MessageFlagsEphemeral,
Content: "Got it chief.",
},
})
if err != nil {
log.Error().Ctx(ctx).Err(err).Msg("failed to respond to interaction")
return fmt.Errorf("failed to respond to interaction: %w", err)
}
return nil
},
"flush": func(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) {
cid := correlation.FromContext(ctx)
"flush": func(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) error {
baggage := make(url.Values)
baggage.Set("correlation_id", cid.String())
state := baggage.Encode()
propagator.Inject(ctx, correlation.UrlValuesCarrier(baggage))
sb := strings.Builder{}
sb.WriteString("modal_flush")
if state != "" {
if len(baggage) != 0 {
sb.WriteRune(':')
sb.WriteString(state)
sb.WriteString(baggage.Encode())
}
if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
@ -506,14 +540,14 @@ func main() {
},
},
}); err != nil {
log.Error().Ctx(ctx).Err(err).Msg("failed to respond to interaction")
return fmt.Errorf("failed to respond to interaction: %w", err)
}
return nil
},
"query": func(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) {
"query": func(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) error {
roDB, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?cache=private&mode=ro", *dbFile))
if err != nil {
log.Error().Ctx(ctx).Err(err).Msg("failed to open read-only copy of database")
return
return fmt.Errorf("failed to open read-only copy of database: %w", err)
}
q := i.ApplicationCommandData().Options[0].StringValue()
@ -521,14 +555,12 @@ func main() {
defer cancelDeadline()
rows, err := roDB.QueryContext(deadlined, q)
if err != nil {
log.Error().Ctx(ctx).Err(err).Msg("failed to exec user-provided query")
return
return fmt.Errorf("faied to exec user-provided query: %w", err)
}
fmtd, err := themis.FormatRows(ctx, rows)
if err != nil {
log.Error().Ctx(ctx).Err(err).Msg("failed to format rows")
return
return fmt.Errorf("failed to format rows: %w", err)
}
// 2000 is a magic number here, it's the character limit for a discord
@ -543,9 +575,11 @@ func main() {
},
}); err != nil {
log.Error().Ctx(ctx).Err(err).Msg("failed to respond to interaction")
return fmt.Errorf("failed to respond to interaction: %w", err)
}
return nil
},
"schedule": func(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) {
"schedule": func(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) error {
// get schedule from now to 4 mondays into the future
sched, err := store.GetSchedule(ctx, themis.NextMonday(), themis.NextMonday().Add(4*7*24*time.Hour))
if err != nil {
@ -555,10 +589,9 @@ func main() {
Content: "failed to get schedule, check logs for more info.",
},
}); err != nil {
log.Error().Ctx(ctx).Err(err).Msg("failed to respond to interaction")
return fmt.Errorf("failed to respond to interaction: %w", err)
}
log.Error().Ctx(ctx).Err(err).Msg("failed to get schedule")
return
return fmt.Errorf("failed to get schedule: %w", err)
}
sb := strings.Builder{}
@ -588,21 +621,26 @@ func main() {
},
}); err != nil {
log.Error().Ctx(ctx).Err(err).Msg("failed to respond to interaction")
return fmt.Errorf("failed to respond to interaction: %w", err)
}
return nil
},
"send-schedule": func(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) {
notifier.Send()
"send-schedule": func(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) error {
notifier.Send(ctx)
if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Flags: discordgo.MessageFlagsEphemeral,
Content: "Done.",
},
}); err != nil {
log.Error().Ctx(ctx).Err(err).Msg("failed to respond to interaction")
return fmt.Errorf("failed to respond to interaction: %w", err)
}
return nil
},
"absent": func(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) {
"absent": func(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) error {
var rawDate string
if len(i.ApplicationCommandData().Options) == 0 {
rawDate = themis.NextMonday().Format(time.DateOnly)
@ -615,37 +653,40 @@ func main() {
if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Flags: discordgo.MessageFlagsEphemeral,
Content: "failed to parse provided date, make sure to use the YYYY-MM-DD format.",
},
}); err != nil {
log.Error().Ctx(ctx).Err(err).Msg("failed to respond to interaction")
return fmt.Errorf("failed to respond to interaction: %w", err)
}
return
return nil
}
if date.Before(time.Now()) {
if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Flags: discordgo.MessageFlagsEphemeral,
Content: "The date must be some time in the future.",
},
}); err != nil {
log.Error().Ctx(ctx).Err(err).Msg("failed to respond to interaction")
return fmt.Errorf("failed to respond to interaction: %w", err)
}
return
return nil
}
if date.Weekday() != time.Monday {
if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Flags: discordgo.MessageFlagsEphemeral,
Content: "The date you provided is not a Monday.",
},
}); err != nil {
log.Error().Ctx(ctx).Err(err).Msg("failed to respond to interaction")
return fmt.Errorf("failed to respond to interaction: %w", err)
}
// TODO(wperron) suggest Mondays before and after?
return
return nil
}
userId := i.Member.User.ID
@ -656,20 +697,22 @@ func main() {
Content: "something went wrong recording your absence, check logs for more info.",
},
}); err != nil {
log.Error().Ctx(ctx).Err(err).Msg("failed to respond to interaction")
return fmt.Errorf("failed to respond to interaction: %w", err)
}
return
return fmt.Errorf("failed to record absence: %w", err)
}
err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Flags: discordgo.MessageFlagsEphemeral,
Content: "Okey dokey.",
},
})
if err != nil {
log.Error().Ctx(ctx).Err(err).Msg("failed to respond to interaction")
return fmt.Errorf("failed to respond to interaction: %w", err)
}
return nil
},
}
@ -688,7 +731,13 @@ func main() {
}
log.Info().Int("count", len(created)).Dur("startup_latency_ms", time.Since(start)).Msg("registered commands, ready to operate")
go notifier.NotifyFunc(ctx, func() {
go notifier.NotifyFunc(ctx, func(ctx context.Context) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
ctx, span := tracer.Start(ctx, "weekly_notification")
defer span.End()
log.Info().Msg("sending weekly reminder")
absentees, err := store.GetAbsentees(ctx, themis.NextMonday())
if err != nil {
@ -704,7 +753,7 @@ func main() {
var msg string
var components []discordgo.MessageComponent
if len(absentees) == 0 {
msg = "Everybody can make it next Monday, see you then!"
msg = "Everybody can make it next Monday, see you then! 🎉"
components = []discordgo.MessageComponent{
discordgo.ActionsRow{
Components: []discordgo.MessageComponent{
@ -712,12 +761,16 @@ func main() {
CustomID: "schedule-response",
Label: "I Can't Make It",
Style: discordgo.DangerButton,
Disabled: false,
Emoji: discordgo.ComponentEmoji{
Name: "🙁",
},
},
},
},
}
} else {
msg = fmt.Sprintf("%s can't make it next Monday. :sad:", themis.FormatStringSlice(absentees))
msg = fmt.Sprintf("%s can't make it next Monday. 🙁", themis.FormatStringSlice(absentees))
}
_, err = discord.ChannelMessageSendComplex(channelId, &discordgo.MessageSend{
@ -731,7 +784,7 @@ func main() {
<-ctx.Done()
log.Info().Msg("context cancelled, exiting")
os.Exit(0)
store.Close()
}
func touchDbFile(path string) error {
@ -762,10 +815,9 @@ func registerHandlers(sess *discordgo.Session, handlers map[string]Handler) {
case discordgo.InteractionApplicationCommand:
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ctx = context.WithValue(ctx, "correlation_id", gen.Next())
if h, ok := handlers[i.ApplicationCommandData().Name]; ok {
withLogging(i.ApplicationCommandData().Name, h)(ctx, s, i)
_ = inSpan(i.ApplicationCommandData().Name, withLogging(i.ApplicationCommandData().Name, h))(ctx, s, i)
}
case discordgo.InteractionModalSubmit:
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
@ -773,16 +825,11 @@ func registerHandlers(sess *discordgo.Session, handlers map[string]Handler) {
state, err := parseCustomIDState(i.ModalSubmitData().CustomID)
if err != nil {
log.Error().Ctx(ctx).Err(err).Msg("unexpected error occured while parsing state from custom id, returning early.")
log.Error().Ctx(ctx).Err(err).Msg("unexpected error occurred while parsing state from custom id, returning early.")
return
}
cid := state.Get("correlation_id")
if cid != "" {
ctx = context.WithValue(ctx, "correlation_id", cid)
} else {
ctx = context.WithValue(ctx, "correlation_id", gen.Next())
}
ctx = propagator.Extract(ctx, correlation.UrlValuesCarrier(state))
if strings.HasPrefix(i.ModalSubmitData().CustomID, "modal_flush") {
sub := i.ModalSubmitData().Components[0].(*discordgo.ActionsRow).Components[0].(*discordgo.TextInput).Value
@ -790,7 +837,7 @@ func registerHandlers(sess *discordgo.Session, handlers map[string]Handler) {
log.Debug().Ctx(ctx).Str("value", sub).Msg("flush modal submitted")
if sub == "y" || sub == "ye" || sub == "yes" {
err := store.Flush(context.Background(), i.Member.User.ID)
err := store.Flush(ctx, i.Member.User.ID)
msg := "Flushed all claims!"
if err != nil {
log.Error().Ctx(ctx).Err(err).Msg("failed to flush claims")
@ -830,19 +877,14 @@ func registerHandlers(sess *discordgo.Session, handlers map[string]Handler) {
return
}
cid := state.Get("correlation_id")
if cid != "" {
ctx = context.WithValue(ctx, "correlation_id", cid)
} else {
ctx = context.WithValue(ctx, "correlation_id", gen.Next())
}
ctx = propagator.Extract(ctx, correlation.UrlValuesCarrier(state))
switch i.MessageComponentData().CustomID {
case "schedule-response":
userId := i.Member.User.ID
log.Info().Ctx(ctx).Str("message_component", "schedule-response").Str("userid", userId).Msg("handling message component interaction")
if err := store.AddAbsence(context.TODO(), themis.NextMonday(), userId); err != nil {
if err := store.AddAbsence(ctx, themis.NextMonday(), userId); err != nil {
if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
@ -942,55 +984,57 @@ func serve(address string) error {
return http.ListenAndServe(address, nil)
}
func inSpan(name string, h Handler) Handler {
return func(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) error {
ctx, span := tracer.Start(ctx, fmt.Sprintf("discord_command %s", name))
defer span.End()
return h(ctx, s, i)
}
}
func withLogging(name string, h Handler) Handler {
return func(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) {
return func(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) error {
start := time.Now()
logCommandInvocation(ctx, name, s, i)
h(ctx, s, i)
debugCommandCompletion(ctx, name, time.Since(start), s, i)
err := h(ctx, s, i)
debugCommandCompletion(ctx, name, time.Since(start), err, s, i)
return nil
}
}
func logCommandInvocation(ctx context.Context, name string, s *discordgo.Session, i *discordgo.InteractionCreate) {
log.Info().
Ctx(ctx).
Str("userid", i.Member.User.ID).
Str("username", i.Member.User.Username).
Str("command", name).
Str("params", func() string {
p := make([]string, 0, len(i.ApplicationCommandData().Options))
for _, o := range i.ApplicationCommandData().Options {
sb := strings.Builder{}
sb.WriteString(o.Name)
sb.WriteRune('=')
span := trace.SpanFromContext(ctx)
span.SetAttributes(
attribute.String("user_id", i.Member.User.ID),
attribute.String("username", i.Member.User.Username),
attribute.String("command_name", name),
)
for _, o := range i.ApplicationCommandData().Options {
switch o.Type {
case discordgo.ApplicationCommandOptionSubCommand, discordgo.ApplicationCommandOptionSubCommandGroup:
panic("unreachable")
case discordgo.ApplicationCommandOptionString:
sb.WriteString(o.StringValue())
span.SetAttributes(attribute.String(fmt.Sprintf("command_option.%s", o.Name), o.StringValue()))
case discordgo.ApplicationCommandOptionInteger:
sb.WriteString(fmt.Sprint(o.IntValue()))
span.SetAttributes(attribute.Int64(fmt.Sprintf("command_option.%s", o.Name), o.IntValue()))
case discordgo.ApplicationCommandOptionBoolean:
sb.WriteString(fmt.Sprint(o.BoolValue()))
span.SetAttributes(attribute.Bool(fmt.Sprintf("command_option.%s", o.Name), o.BoolValue()))
default:
sb.WriteString("[unsupported type]")
span.SetAttributes(attribute.String(fmt.Sprintf("command_option.%s", o.Name), "unsupported_type"))
}
p = append(p, sb.String())
}
return strings.Join(p, ", ")
}()).
Msg("command invoked")
log.Info().Ctx(ctx).Msg("command invoked")
}
func debugCommandCompletion(ctx context.Context, name string, dur time.Duration, s *discordgo.Session, i *discordgo.InteractionCreate) {
log.Info().
Ctx(ctx).
Str("userid", i.Member.User.ID).
Str("username", i.Member.User.Username).
Str("command", name).
Dur("latency_ms", dur).
Msg("command completed")
func debugCommandCompletion(ctx context.Context, name string, dur time.Duration, err error, s *discordgo.Session, i *discordgo.InteractionCreate) {
span := trace.SpanFromContext(ctx)
if err != nil {
span.SetStatus(codes.Error, err.Error())
}
log.Info().Ctx(ctx).Dur("latency_ms", dur).Msg("command completed")
}
func min(a, b int) int {
@ -1012,3 +1056,36 @@ func parseCustomIDState(qs string) (url.Values, error) {
}
return v, nil
}
func initTracing(ctx context.Context, db *sql.DB) error {
fd, _ := flydetector.NewDetector().Detect(ctx) // this can't error
rd, err := resource.New(ctx,
resource.WithHost(),
resource.WithOS(),
resource.WithProcess(),
resource.WithTelemetrySDK(),
resource.WithAttributes(semconv.ServiceName("themis")),
)
if err != nil {
return fmt.Errorf("failed to create resource: %w", err)
}
res, err := resource.Merge(fd, rd)
if err != nil {
return fmt.Errorf("failed to merge resources: %w", err)
}
ex, err := sqliteexporter.NewSqliteSDKTraceExporterWithDB(db)
if err != nil {
return fmt.Errorf("failed to create span exporter: %w", err)
}
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(ex, sdktrace.WithExportTimeout(time.Second)),
sdktrace.WithResource(res),
)
otel.SetTracerProvider(tp)
tracer = tp.Tracer("themis")
return nil
}

@ -5,6 +5,10 @@ import (
"fmt"
"github.com/rs/zerolog/log"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
"go.opentelemetry.io/otel/trace"
)
type Conflict struct {
@ -19,37 +23,47 @@ func (c Conflict) String() string {
return fmt.Sprintf("%s owned by #%d %s %s (%s)", c.Province, c.ClaimID, c.ClaimType, c.Claim, c.Player)
}
const conflictQuery string = `SELECT name, player, claim_type, val, id FROM (
SELECT provinces.name, claims.player, claims.claim_type, claims.val, claims.id
FROM claims
LEFT JOIN provinces ON claims.val = provinces.trade_node
WHERE claims.claim_type = 'trade' AND claims.userid IS NOT ?
AND provinces.%[1]s = ?
UNION
SELECT provinces.name, claims.player, claims.claim_type, claims.val, claims.id
FROM claims
LEFT JOIN provinces ON claims.val = provinces.region
WHERE claims.claim_type = 'region' AND claims.userid IS NOT ?
AND provinces.%[1]s = ?
UNION
SELECT provinces.name, claims.player, claims.claim_type, claims.val, claims.id
const conflictQuery string = `WITH claiming AS (
SELECT province FROM claimables
WHERE claimables.typ = ?
AND claimables.name = ?
)
SELECT claimables.province, claims.player, claims.claim_type, claims.val, claims.id
FROM claims
LEFT JOIN provinces ON claims.val = provinces.area
WHERE claims.claim_type = 'area' AND claims.userid IS NOT ?
AND provinces.%[1]s = ?
);`
INNER JOIN claimables
ON claims.claim_type = claimables.typ
AND claims.val = claimables.name
INNER JOIN claiming
ON claiming.province = claimables.province
WHERE claims.userid IS NOT ?;`
func (s *Store) FindConflicts(ctx context.Context, userId, name string, claimType ClaimType) ([]Conflict, error) {
ctx, span := tracer.Start(ctx, "find_conflicts", trace.WithAttributes(
semconv.DBSystemSqlite,
semconv.DBSQLTable("claims"),
semconv.DBOperation("select"),
attribute.String("user_id", userId),
attribute.String("claim_name", name),
attribute.Stringer("claim_type", claimType),
))
defer span.End()
log.Debug().Ctx(ctx).Stringer("claim_type", claimType).Str("userid", userId).Msg("searching for potential conflicts")
stmt, err := s.db.PrepareContext(ctx, fmt.Sprintf(conflictQuery, claimTypeToColumn[claimType]))
stmt, err := s.db.PrepareContext(ctx, conflictQuery)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to prepare query")
return nil, fmt.Errorf("failed to prepare conflicts query: %w", err)
}
rows, err := stmt.QueryContext(ctx, userId, name, userId, name, userId, name)
rows, err := stmt.QueryContext(ctx, claimType, name, userId)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to execute query")
return nil, fmt.Errorf("failed to get conflicting provinces: %w", err)
}
defer stmt.Close()
conflicts := make([]Conflict, 0)
for rows.Next() {
@ -62,6 +76,8 @@ func (s *Store) FindConflicts(ctx context.Context, userId, name string, claimTyp
)
err = rows.Scan(&province, &player, &sClaimType, &claimName, &claimId)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to scan row")
return nil, fmt.Errorf("failed to scan row: %w", err)
}

@ -2,19 +2,24 @@ package themis
import (
"context"
"database/sql"
"fmt"
"reflect"
"testing"
"github.com/rs/zerolog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestStore_FindConflicts(t *testing.T) {
store, err := NewStore(fmt.Sprintf(TEST_CONN_STRING_PATTERN, "TestStore_FindConflicts"))
db, err := sql.Open("sqlite3", fmt.Sprintf(TEST_CONN_STRING_PATTERN, "TestStore_FindConflicts"))
require.NoError(t, err)
store, err := NewStore(db, zerolog.Nop())
assert.NoError(t, err)
id, err := store.Claim(context.TODO(), "000000000000000001", "foo", "Bordeaux", CLAIM_TYPE_TRADE)
assert.NoError(t, err)
id, err := store.Claim(context.Background(), "000000000000000001", "foo", "Bordeaux", CLAIM_TYPE_TRADE)
require.NoError(t, err)
type args struct {
ctx context.Context
@ -42,7 +47,7 @@ func TestStore_FindConflicts(t *testing.T) {
{
name: "overlapping",
args: args{
context.TODO(),
context.Background(),
"000000000000000002",
"Iberia",
CLAIM_TYPE_REGION,

@ -1,16 +0,0 @@
package zerolog
import (
zl "github.com/rs/zerolog"
"go.wperron.io/themis/correlation"
)
type CorrelationHook struct{}
func (h CorrelationHook) Run(e *zl.Event, level zl.Level, msg string) {
ctx := e.GetCtx()
c := correlation.FromContext(ctx)
if c != nil {
e.Stringer("correlation_id", c)
}
}

@ -1,47 +0,0 @@
package correlation
import (
"context"
"encoding/hex"
)
const Key string = "correlation_id"
var Empty CorrelationID = CorrelationID{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
// Correlation ID is a byte array of length 16
type CorrelationID []byte
func (ci CorrelationID) String() string {
if ci == nil {
return hex.EncodeToString(Empty)
}
return hex.EncodeToString(ci)
}
func FromContext(ctx context.Context) CorrelationID {
if v := ctx.Value("correlation_id"); v != nil {
if c, ok := v.(CorrelationID); ok {
return c
}
}
return nil
}
type Sequencer interface {
Next() []byte
}
type Generator struct {
seq Sequencer
}
func NewGenerator(seq Sequencer) *Generator {
return &Generator{
seq: seq,
}
}
func (g *Generator) Next() CorrelationID {
return CorrelationID(g.seq.Next())
}

@ -1,18 +0,0 @@
package correlation
import (
"math/rand"
"testing"
"github.com/stretchr/testify/assert"
)
func TestGeneratorNext(t *testing.T) {
rand.Seed(0)
seq := &MathRandSequencer{}
gen := NewGenerator(seq)
assert.Equal(t, "0194fdc2fa2ffcc041d3ff12045b73c8", gen.Next().String())
assert.Equal(t, "6e4ff95ff662a5eee82abdf44a2d0b75", gen.Next().String())
assert.Equal(t, "fb180daf48a79ee0b10d394651850fd4", gen.Next().String())
}

@ -1,19 +0,0 @@
package correlation
import "crypto/rand"
type CryptoRandSequencer struct{}
func (crs *CryptoRandSequencer) Next() []byte {
buf := make([]byte, 16)
read, err := rand.Read(buf)
if err != nil {
panic("not implemented")
}
if read != 16 {
panic("todo")
}
return buf
}

@ -1,18 +0,0 @@
package correlation
import "math/rand"
type MathRandSequencer struct{}
func (mrs *MathRandSequencer) Next() []byte {
buf := make([]byte, 16)
read, err := rand.Read(buf)
if err != nil {
panic("not implemented")
}
if read != 16 {
panic("todo")
}
return buf
}

@ -0,0 +1,44 @@
package correlation
import (
"net/url"
"github.com/rs/zerolog"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/trace"
)
type TraceContextHook struct{}
func (h TraceContextHook) Run(e *zerolog.Event, level zerolog.Level, msg string) {
ctx := e.GetCtx()
spanContext := trace.SpanContextFromContext(ctx)
trace_id := spanContext.TraceID()
if trace_id.IsValid() {
e.Stringer("trace_id", trace_id)
}
}
var _ propagation.TextMapCarrier = UrlValuesCarrier{}
type UrlValuesCarrier url.Values
// Get implements propagation.TextMapCarrier.
func (u UrlValuesCarrier) Get(key string) string {
return url.Values(u).Get(key)
}
// Keys implements propagation.TextMapCarrier.
func (u UrlValuesCarrier) Keys() []string {
raw := map[string][]string(u)
ks := make([]string, 0, len(raw))
for k := range raw {
ks = append(ks, k)
}
return ks
}
// Set implements propagation.TextMapCarrier.
func (u UrlValuesCarrier) Set(key string, value string) {
url.Values(u).Set(key, value)
}

@ -7,6 +7,7 @@ processes = []
[env]
TZ = "America/New_York"
ENV = "production"
DISCORD_APP_ID = "1014881815921705030"
DISCORD_GUILD_ID = "375417755777892353"
DISCORD_BOT_CHANNEL_ID = "1018997240968265768"

@ -7,13 +7,23 @@ import (
"strings"
"github.com/rs/zerolog/log"
"go.opentelemetry.io/otel/codes"
semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
"go.opentelemetry.io/otel/trace"
)
func FormatRows(ctx context.Context, rows *sql.Rows) (string, error) {
ctx, span := tracer.Start(ctx, "format_rows", trace.WithAttributes(
semconv.DBSystemSqlite,
))
defer span.End()
sb := strings.Builder{}
cols, err := rows.Columns()
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to get rows columns")
return "", fmt.Errorf("failed to get rows columns: %w", err)
}
@ -37,6 +47,8 @@ func FormatRows(ctx context.Context, rows *sql.Rows) (string, error) {
row[i] = new(sql.NullString)
}
if err := rows.Scan(row...); err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to scan row")
return "", fmt.Errorf("failed to scan next row: %w", err)
}

@ -2,14 +2,19 @@ package themis
import (
"context"
"database/sql"
"fmt"
"testing"
"github.com/rs/zerolog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestFormatRows(t *testing.T) {
store, err := NewStore(fmt.Sprintf(TEST_CONN_STRING_PATTERN, "format-rows"))
db, err := sql.Open("sqlite3", fmt.Sprintf(TEST_CONN_STRING_PATTERN, "format-rows"))
require.NoError(t, err)
store, err := NewStore(db, zerolog.Nop())
assert.NoError(t, err)
rows, err := store.db.Query("SELECT provinces.name, provinces.region, provinces.area, provinces.trade_node FROM provinces WHERE area = 'Gascony'")
@ -27,7 +32,9 @@ func TestFormatRows(t *testing.T) {
}
func TestFormatRowsAggregated(t *testing.T) {
store, err := NewStore(fmt.Sprintf(TEST_CONN_STRING_PATTERN, "format-rows"))
db, err := sql.Open("sqlite3", fmt.Sprintf(TEST_CONN_STRING_PATTERN, "format-rows"))
require.NoError(t, err)
store, err := NewStore(db, zerolog.Nop())
assert.NoError(t, err)
rows, err := store.db.Query("SELECT count(1) as total, trade_node from provinces where region = 'France' group by trade_node")
@ -46,7 +53,9 @@ func TestFormatRowsAggregated(t *testing.T) {
}
func TestFormatRowsInvalidQuery(t *testing.T) {
store, err := NewStore(fmt.Sprintf(TEST_CONN_STRING_PATTERN, "format-rows"))
db, err := sql.Open("sqlite3", fmt.Sprintf(TEST_CONN_STRING_PATTERN, "format-rows"))
require.NoError(t, err)
store, err := NewStore(db, zerolog.Nop())
assert.NoError(t, err)
_, err = store.db.Query("SELECT count(name), distinct(trade_node) from provinces where region = 'France'")

@ -1,13 +1,54 @@
module go.wperron.io/themis
go 1.21
go 1.21.1
require (
github.com/bwmarrin/discordgo v0.26.1
github.com/golang-migrate/migrate/v4 v4.16.2
github.com/mattn/go-sqlite3 v1.14.16
github.com/golang-migrate/migrate/v4 v4.17.0
github.com/mattn/go-sqlite3 v1.14.19
github.com/rs/zerolog v1.31.0
github.com/stretchr/testify v1.8.1
github.com/stretchr/testify v1.8.4
go.opentelemetry.io/otel/trace v1.24.0
go.wperron.io/sqliteexporter v0.1.0
)
require (
github.com/cenkalti/backoff/v4 v4.2.1 // indirect
github.com/go-logr/logr v1.4.1 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/hashicorp/go-version v1.6.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/knadh/koanf/maps v0.1.1 // indirect
github.com/knadh/koanf/providers/confmap v0.1.0 // indirect
github.com/knadh/koanf/v2 v2.0.2 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
go.opencensus.io v0.24.0 // indirect
go.opentelemetry.io/collector v0.92.0 // indirect
go.opentelemetry.io/collector/component v0.94.1 // indirect
go.opentelemetry.io/collector/config/configretry v0.92.0 // indirect
go.opentelemetry.io/collector/config/configtelemetry v0.94.1 // indirect
go.opentelemetry.io/collector/confmap v0.94.1 // indirect
go.opentelemetry.io/collector/consumer v0.92.0 // indirect
go.opentelemetry.io/collector/exporter v0.92.0 // indirect
go.opentelemetry.io/collector/extension v0.92.0 // indirect
go.opentelemetry.io/collector/featuregate v1.0.1 // indirect
go.opentelemetry.io/collector/pdata v1.1.0 // indirect
go.opentelemetry.io/otel/metric v1.24.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.26.0 // indirect
go.wperron.io/flydetector v0.0.0-20240302202855-7f606a93cdb6 // indirect
golang.org/x/net v0.20.0 // indirect
golang.org/x/text v0.14.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20231106174013-bbf56f31fb17 // indirect
google.golang.org/grpc v1.61.0 // indirect
google.golang.org/protobuf v1.32.0 // indirect
)
require (
@ -18,8 +59,10 @@ require (
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
go.opentelemetry.io/otel v1.24.0
go.opentelemetry.io/otel/sdk v1.24.0
go.uber.org/atomic v1.7.0 // indirect
golang.org/x/crypto v0.7.0 // indirect
golang.org/x/sys v0.12.0 // indirect
golang.org/x/crypto v0.18.0 // indirect
golang.org/x/sys v0.17.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

258
go.sum

@ -1,12 +1,70 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
contrib.go.opencensus.io/exporter/prometheus v0.4.2 h1:sqfsYl5GIY/L570iT+l93ehxaWJs2/OwXtiWwew3oAg=
contrib.go.opencensus.io/exporter/prometheus v0.4.2/go.mod h1:dvEHbiKmgvbr5pjaF9fpw1KeYcjrnC1J8B+JKjsZyRQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bwmarrin/discordgo v0.26.1 h1:AIrM+g3cl+iYBr4yBxCBp9tD9jR3K7upEjl0d89FRkE=
github.com/bwmarrin/discordgo v0.26.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM=
github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/go-kit/log v0.2.1 h1:MRVx0/zhvdseW+Gza6N9rVzU/IVzaeE1SFI4raAhmBU=
github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0=
github.com/go-logfmt/logfmt v0.5.1 h1:otpy5pqBCBZ1ng9RQ0dPu4PN7ba75Y/aA+UpowDyNVA=
github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1 h1:TQcrn6Wq+sKGkpyPvppOz99zsMBaUOKXq6HSv655U1c=
github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang-migrate/migrate/v4 v4.16.2 h1:8coYbMKUyInrFk1lfGfRovTLAW7PhWp8qQDT2iKfuoA=
github.com/golang-migrate/migrate/v4 v4.16.2/go.mod h1:pfcJX4nPHaVdc5nmdCikFBWtm+UBpiZjRNNsyBbp0/o=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-migrate/migrate/v4 v4.17.0 h1:rd40H3QXU0AA4IoLllFcEAEo9dYKRHYND2gB4p7xcaU=
github.com/golang-migrate/migrate/v4 v4.17.0/go.mod h1:+Cp2mtLP4/aXDTKb9wmXYitdrNx2HGs45rbWAo6OsKM=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
@ -14,18 +72,58 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8=
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek=
github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/knadh/koanf/maps v0.1.1 h1:G5TjmUh2D7G2YWf5SQQqSiHRJEjaicvU0KpypqB3NIs=
github.com/knadh/koanf/maps v0.1.1/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
github.com/knadh/koanf/providers/confmap v0.1.0 h1:gOkxhHkemwG4LezxxN8DMOFopOPghxRVp7JbIvdvqzU=
github.com/knadh/koanf/providers/confmap v0.1.0/go.mod h1:2uLhxQzJnyHKfxG927awZC7+fyHFdQkd697K4MdLnIU=
github.com/knadh/koanf/v2 v2.0.2 h1:sEZzPW2rVWSahcYILNq/syJdEyRafZIG0l9aWwL86HA=
github.com/knadh/koanf/v2 v2.0.2/go.mod h1:HN9uZ+qFAejH1e4G41gnoffIanINWQuONLXiV7kir6k=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mattn/go-sqlite3 v1.14.19 h1:fhGleo2h1p8tVChob4I9HpmVFIAkKGpiukdrgQbWfGI=
github.com/mattn/go-sqlite3 v1.14.19/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c h1:cqn374mizHuIWj+OSJCajGr/phAmuMug9qIX3l9CflE=
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk=
github.com/prometheus/client_golang v1.18.0/go.mod h1:T+GXkCk5wSJyOqMIzVgvvjFDlkOQntgjkJWKrN5txjA=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
github.com/prometheus/common v0.46.0 h1:doXzt5ybi1HBKpsZOL0sSkaNHJJqkyfEWZGGqqScV0Y=
github.com/prometheus/common v0.46.0/go.mod h1:Tp0qkxpb9Jsg54QMe+EAmqXkSV7Evdy1BTn+g2pa/hQ=
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
github.com/prometheus/statsd_exporter v0.22.7 h1:7Pji/i2GuhK6Lu7DHrtTkFmNBCudCPT1pX2CziuyQR0=
github.com/prometheus/statsd_exporter v0.22.7/go.mod h1:N/TevpjkIh9ccs6nuzY3jQn9dFqnUakOjnEuMPJJJnI=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.31.0 h1:FcTR3NnLWW+NnTwwhFWiJSZr4ECLpqCm6QsEnyvbV4A=
github.com/rs/zerolog v1.31.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
@ -35,24 +133,162 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/collector v0.92.0 h1:XiC0ptaT1EmOkK2RI0gt3n2tkzLAkNQGf0E7hrGdyeA=
go.opentelemetry.io/collector v0.92.0/go.mod h1:wbksjM63DTKA1BbdUVS7gAFzAngCZTWb46RBpKdtsPw=
go.opentelemetry.io/collector/component v0.94.1 h1:j4peKsWb+QVBKPs2RJeIj5EoQW7yp2ZVGrd8Bu9HU9M=
go.opentelemetry.io/collector/component v0.94.1/go.mod h1:vg+kAH81C3YS0SPzUXkSFWLPC1WH7zx70dAtUWWIHcE=
go.opentelemetry.io/collector/config/configretry v0.92.0 h1:3WUabmCRIBHSkOLGCHGieUGchlHkBw3Fa4Cj9Do5Xdw=
go.opentelemetry.io/collector/config/configretry v0.92.0/go.mod h1:gt1HRYyMxcMca9lbDLPbivQzsUCjVjkPAn/3S6fiD14=
go.opentelemetry.io/collector/config/configtelemetry v0.94.1 h1:ztYpBEBlvhcoxMiDKNmQ2SS+A41JZ4M19GfcxjCo8Zs=
go.opentelemetry.io/collector/config/configtelemetry v0.94.1/go.mod h1:2XLhyR/GVpWeZ2K044vCmrvH/d4Ewt0aD/y46avZyMU=
go.opentelemetry.io/collector/confmap v0.94.1 h1:O69bkeyR1YPAFz+jMd45aDZc1DtYnwb3Skgr2yALPqQ=
go.opentelemetry.io/collector/confmap v0.94.1/go.mod h1:pCT5UtcHaHVJ5BIILv1Z2VQyjZzmT9uTdBmC9+Z0AgA=
go.opentelemetry.io/collector/consumer v0.92.0 h1:twa8T0iR9KVglvRbwZ5OPKLXPCC2DO6gVhrgDZ47MPE=
go.opentelemetry.io/collector/consumer v0.92.0/go.mod h1:fBZqP7bou3I7pDhWjleBuzdaLfQgJBc92wPJVOcKaGU=
go.opentelemetry.io/collector/exporter v0.92.0 h1:z6u+/hswJUuZbuPYIF2gXMZsqjIDd/tJO60XjLM850U=
go.opentelemetry.io/collector/exporter v0.92.0/go.mod h1:54ODYn1weY/Wr0bdxESj4P1fgyX+zaUsnJJnafORqIY=
go.opentelemetry.io/collector/extension v0.92.0 h1:zaehgW+LXCMNEb1d6Af/VHWphh5ZwX9aZl+NuQLGhpQ=
go.opentelemetry.io/collector/extension v0.92.0/go.mod h1:5EYwiaGU6deSY8YWqT5gvlnD850yJXP3NqFRKVVbYLs=
go.opentelemetry.io/collector/featuregate v1.0.1 h1:ok//hLSXttBbyu4sSV1pTx1nKdr5udSmrWy5sFMIIbM=
go.opentelemetry.io/collector/featuregate v1.0.1/go.mod h1:QQXjP4etmJQhkQ20j4P/rapWuItYxoFozg/iIwuKnYg=
go.opentelemetry.io/collector/pdata v1.1.0 h1:cE6Al1rQieUjMHro6p6cKwcu3sjHXGG59BZ3kRVUvsM=
go.opentelemetry.io/collector/pdata v1.1.0/go.mod h1:IDkDj+B4Fp4wWOclBELN97zcb98HugJ8Q2gA4ZFsN8Q=
go.opentelemetry.io/collector/receiver v0.92.0 h1:TRz4ufr5bFEszpAWgYVEx/b7VPZzEcECsyMzztf5PsQ=
go.opentelemetry.io/collector/receiver v0.92.0/go.mod h1:bYAAYbMuUVj3wx7ave2iyyJ+aGUpACliYOQ5xI92I7k=
go.opentelemetry.io/otel v1.23.0 h1:Df0pqjqExIywbMCMTxkAwzjLZtRf+bBKLbUcpxO2C9E=
go.opentelemetry.io/otel v1.23.0/go.mod h1:YCycw9ZeKhcJFrb34iVSkyT0iczq/zYDtZYFufObyB0=
go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo=
go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo=
go.opentelemetry.io/otel/exporters/prometheus v0.45.1 h1:R/bW3afad6q6VGU+MFYpnEdo0stEARMCdhWu6+JI6aI=
go.opentelemetry.io/otel/exporters/prometheus v0.45.1/go.mod h1:wnHAfKRav5Dfp4iZhyWZ7SzQfT+rDZpEpYG7To+qJ1k=
go.opentelemetry.io/otel/metric v1.23.0 h1:pazkx7ss4LFVVYSxYew7L5I6qvLXHA0Ap2pwV+9Cnpo=
go.opentelemetry.io/otel/metric v1.23.0/go.mod h1:MqUW2X2a6Q8RN96E2/nqNoT+z9BSms20Jb7Bbp+HiTo=
go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI=
go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco=
go.opentelemetry.io/otel/sdk v1.23.0 h1:0KM9Zl2esnl+WSukEmlaAEjVY5HDZANOHferLq36BPc=
go.opentelemetry.io/otel/sdk v1.23.0/go.mod h1:wUscup7byToqyKJSilEtMf34FgdCAsFpFOjXnAwFfO0=
go.opentelemetry.io/otel/sdk v1.24.0 h1:YMPPDNymmQN3ZgczicBY3B6sf9n62Dlj9pWD3ucgoDw=
go.opentelemetry.io/otel/sdk v1.24.0/go.mod h1:KVrIYw6tEubO9E96HQpcmpTKDVn9gdv35HoYiQWGDFg=
go.opentelemetry.io/otel/sdk/metric v1.23.0 h1:u81lMvmK6GMgN4Fty7K7S6cSKOZhMKJMK2TB+KaTs0I=
go.opentelemetry.io/otel/sdk/metric v1.23.0/go.mod h1:2LUOToN/FdX6wtfpHybOnCZjoZ6ViYajJYMiJ1LKDtQ=
go.opentelemetry.io/otel/trace v1.23.0 h1:37Ik5Ib7xfYVb4V1UtnT97T1jI+AoIYkJyPkuL4iJgI=
go.opentelemetry.io/otel/trace v1.23.0/go.mod h1:GSGTbIClEsuZrGIzoEHqsVfxgn5UkggkflQwDScNUsk=
go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI=
go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
go.wperron.io/flydetector v0.0.0-20240302202855-7f606a93cdb6 h1:SaV3yjkbcoC6sFgQbQhmVIYN9VSEMmvuUadAzBFe/YA=
go.wperron.io/flydetector v0.0.0-20240302202855-7f606a93cdb6/go.mod h1:XUxNxHOGyI6655abXX5+04qdore1sDRErBVmWFrmZV4=
go.wperron.io/sqliteexporter v0.1.0-rc5.0.20240209234500-ad89647cf9b6 h1:GbIjafkOpD/bWS2qR8PAOQAZPTQTQLk5XC4+d7DI7Fw=
go.wperron.io/sqliteexporter v0.1.0-rc5.0.20240209234500-ad89647cf9b6/go.mod h1:iQD28FG3zrdOEpKTGxvWCcLdir5eavk5bKjjN3RQ6Xc=
go.wperron.io/sqliteexporter v0.1.0 h1:0oK7DxZOMAwaGqRo5FVr9Nba3/IB5uO8Arfvi9rQADo=
go.wperron.io/sqliteexporter v0.1.0/go.mod h1:iQD28FG3zrdOEpKTGxvWCcLdir5eavk5bKjjN3RQ6Xc=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20231106174013-bbf56f31fb17 h1:Jyp0Hsi0bmHXG6k9eATXoYtjd6e2UzZ1SCn/wIupY14=
google.golang.org/genproto/googleapis/rpc v0.0.0-20231106174013-bbf56f31fb17/go.mod h1:oQ5rr10WTTMvP4A36n8JpR1OrO1BEiV4f78CneXZxkA=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.61.0 h1:TOvOcuXn30kRao+gfcvsebNEa5iZIiLkisYEkf7R7o0=
google.golang.org/grpc v1.61.0/go.mod h1:VUbo7IFqmF1QtCAstipjG0GIoq49KvMe9+h1jFLBNJs=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I=
google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

@ -0,0 +1,14 @@
create view if not exists claimables as
with
trades as (select distinct trade_node from provinces where trade_node != ''),
areas as (select distinct area from provinces where area != ''),
regions as (select distinct region from provinces where region != '')
select 'trade' as typ, provinces.trade_node as name, name as province, id
from provinces inner join trades on trades.trade_node = provinces.trade_node
union
select 'area' as typ, provinces.area as name, name as province, id
from provinces inner join areas on areas.area = provinces.area
union
select 'region' as typ, provinces.region as name, name as province, id
from provinces inner join regions on regions.region = provinces.region
;

@ -0,0 +1,15 @@
drop table claimables;
create view if not exists claimables as
with
trades as (select distinct trade_node from provinces where trade_node != ''),
areas as (select distinct area from provinces where area != ''),
regions as (select distinct region from provinces where region != '')
select 'trade' as typ, provinces.trade_node as name, name as province, id
from provinces inner join trades on trades.trade_node = provinces.trade_node
union
select 'area' as typ, provinces.area as name, name as province, id
from provinces inner join areas on areas.area = provinces.area
union
select 'region' as typ, provinces.region as name, name as province, id
from provinces inner join regions on regions.region = provinces.region
;

@ -0,0 +1,15 @@
drop view claimables;
create table if not exists claimables as
with
trades as (select distinct trade_node from provinces where trade_node != ''),
areas as (select distinct area from provinces where area != ''),
regions as (select distinct region from provinces where region != '')
select 'trade' as typ, provinces.trade_node as name, name as province, id
from provinces inner join trades on trades.trade_node = provinces.trade_node
union
select 'area' as typ, provinces.area as name, name as province, id
from provinces inner join areas on areas.area = provinces.area
union
select 'region' as typ, provinces.region as name, name as province, id
from provinces inner join regions on regions.region = provinces.region
;

@ -6,6 +6,7 @@ import (
"time"
"github.com/rs/zerolog/log"
"go.opentelemetry.io/otel/trace"
)
var loc *time.Location
@ -15,10 +16,10 @@ func init() {
}
type Notifier struct {
c chan struct{}
c chan context.Context
}
func NewNotifier(c chan struct{}) *Notifier {
func NewNotifier(c chan context.Context) *Notifier {
return &Notifier{
c: c,
}
@ -45,8 +46,11 @@ func (n *Notifier) Start(ctx context.Context) {
log.Debug().Msg("context deadline exceeded, exiting notifier")
return
default:
log.Debug().Msg("notifier tick")
n.c <- struct{}{}
log.Debug().Str("parent", "ticker").Msg("notifier tick")
ctx, span := tracer.Start(ctx, "notifier_tick", trace.WithNewRoot())
n.c <- ctx
span.End()
}
ticker := time.NewTicker(time.Hour * 24 * 7)
@ -56,27 +60,34 @@ func (n *Notifier) Start(ctx context.Context) {
log.Debug().Msg("context deadline exceeded, exiting notifier")
return
case <-ticker.C:
log.Debug().Msg("notifier tick")
n.c <- struct{}{}
log.Debug().Str("parent", "ticker").Msg("notifier tick")
ctx, span := tracer.Start(ctx, "notifier_tick", trace.WithNewRoot())
n.c <- ctx
span.End()
}
time.Sleep(time.Second)
}
}
// Trigger the notifier manually. Should be used for testing purposes only.
func (n *Notifier) Send() {
n.c <- struct{}{}
func (n *Notifier) Send(ctx context.Context) {
log.Debug().Str("parent", "trigger").Ctx(ctx).Msg("notifier tick")
n.c <- ctx
}
func (n *Notifier) NotifyFunc(ctx context.Context, f func()) {
func (n *Notifier) NotifyFunc(ctx context.Context, f func(context.Context)) {
for {
select {
case <-ctx.Done():
log.Debug().Msg("context deadline exceeded, exiting notify func")
return
case <-n.c:
log.Debug().Msg("tick received, notifying function")
f()
case innerCtx := <-n.c:
innerCtx, span := tracer.Start(innerCtx, "notify_func")
log.Debug().Ctx(innerCtx).Msg("tick received, notifying function")
f(innerCtx)
span.End()
}
time.Sleep(time.Second)
}

@ -4,7 +4,6 @@ import (
"context"
"database/sql"
"embed"
_ "embed"
"errors"
"fmt"
"strings"
@ -13,23 +12,30 @@ import (
"github.com/golang-migrate/migrate/v4/database/sqlite3"
"github.com/golang-migrate/migrate/v4/source/iofs"
_ "github.com/mattn/go-sqlite3"
"github.com/rs/zerolog/log"
"github.com/rs/zerolog"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
"go.opentelemetry.io/otel/trace"
)
//go:embed migrations/*.sql
var migrations embed.FS
type Store struct {
db *sql.DB
var tracer trace.Tracer
func init() {
tp := otel.GetTracerProvider()
tracer = tp.Tracer("themis")
}
func NewStore(conn string) (*Store, error) {
log.Debug().Str("connection_string", conn).Msg("opening sqlite3 database")
db, err := sql.Open("sqlite3", conn)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
type Store struct {
db *sql.DB
logger zerolog.Logger
}
func NewStore(db *sql.DB, logger zerolog.Logger) (*Store, error) {
d, err := iofs.New(migrations, "migrations")
if err != nil {
return nil, fmt.Errorf("failed to open iofs migration source: %w", err)
@ -45,33 +51,45 @@ func NewStore(conn string) (*Store, error) {
return nil, fmt.Errorf("failed to initialize db migrate: %w", err)
}
ver, dirty, err := m.Version()
if err != nil {
return nil, fmt.Errorf("failed to get database migration version: %w", err)
}
log.Debug().Uint("current_version", ver).Bool("dirty", dirty).Msg("running database migrations")
err = m.Up()
if err != nil && !errors.Is(err, migrate.ErrNoChange) {
return nil, fmt.Errorf("failed to roll up migrations: %w", err)
}
ver, dirty, err := m.Version()
if err != nil && err != migrate.ErrNilVersion {
return nil, fmt.Errorf("failed to get database migration version: %w", err)
}
logger.Debug().Uint("current_version", ver).Bool("dirty", dirty).Msg("running database migrations")
return &Store{
logger: logger,
db: db,
}, nil
}
func (s *Store) Close() error {
log.Debug().Msg("closing database")
s.logger.Debug().Msg("closing database")
return s.db.Close()
}
func (s *Store) Claim(ctx context.Context, userId, player, province string, claimType ClaimType) (int, error) {
log.Debug().
ctx, span := tracer.Start(ctx, "claim", trace.WithAttributes(
semconv.DBSystemSqlite,
semconv.DBSQLTable("claims"),
semconv.DBOperation("insert"),
attribute.String("user_id", userId),
attribute.String("claim_name", province),
attribute.Stringer("claim_type", claimType),
))
defer span.End()
s.logger.Debug().
Ctx(ctx).
Str("userid", userId).
Str("player", player).
Str("provice", province).
Str("province", province).
Stringer("claim_type", claimType).
Msg("inserting claim")
audit := &AuditableEvent{
@ -83,6 +101,8 @@ func (s *Store) Claim(ctx context.Context, userId, player, province string, clai
tx, err := s.db.Begin()
if err != nil {
audit.err = err
span.RecordError(err)
span.SetStatus(codes.Error, "failed to begin transaction")
return 0, fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Commit() //nolint:errcheck
@ -90,50 +110,66 @@ func (s *Store) Claim(ctx context.Context, userId, player, province string, clai
conflicts, err := s.FindConflicts(ctx, userId, province, claimType)
if err != nil {
audit.err = err
span.RecordError(err)
span.SetStatus(codes.Error, "conflict check failed")
return 0, fmt.Errorf("failed to run conflicts check: %w", err)
}
if len(conflicts) > 0 {
log.Debug().Ctx(ctx).Int("len", len(conflicts)).Msg("found conflicts")
audit.err = err
s.logger.Debug().Ctx(ctx).Int("len", len(conflicts)).Msg("found conflicts")
audit.err = errors.New("found conflicts")
return 0, ErrConflict{Conflicts: conflicts}
}
// check that provided name matches the claim type
stmt, err := s.db.PrepareContext(ctx, fmt.Sprintf(`SELECT COUNT(1) FROM provinces WHERE LOWER(provinces.%s) = ?`, claimTypeToColumn[claimType]))
stmt, err := s.db.PrepareContext(ctx, `SELECT COUNT(1) FROM claimables WHERE lower(name) = ? and typ = ?`)
if err != nil {
audit.err = err
span.RecordError(err)
span.SetStatus(codes.Error, "failed to prepare query")
return 0, fmt.Errorf("failed to prepare count query: %w", err)
}
defer stmt.Close()
row := stmt.QueryRowContext(ctx, strings.ToLower(province))
row := stmt.QueryRowContext(ctx, strings.ToLower(province), claimType)
var count int
err = row.Scan(&count)
if err != nil {
audit.err = err
span.RecordError(err)
span.SetStatus(codes.Error, "failed to scan row")
return 0, fmt.Errorf("failed to scan: %w", err)
}
if count == 0 {
audit.err = err
span.RecordError(err)
span.SetStatus(codes.Error, "no matching provinces found")
return 0, fmt.Errorf("found no provinces for %s named %s", claimType, province)
}
stmt, err = s.db.PrepareContext(ctx, "INSERT INTO claims (player, claim_type, val, userid) VALUES (?, ?, ?, ?)")
if err != nil {
audit.err = err
span.RecordError(err)
span.SetStatus(codes.Error, "failed to prepare query")
return 0, fmt.Errorf("failed to prepare claim query: %w", err)
}
defer stmt.Close()
res, err := stmt.ExecContext(ctx, player, claimType, province, userId)
if err != nil {
audit.err = err
span.RecordError(err)
span.SetStatus(codes.Error, "failed to execute query")
return 0, fmt.Errorf("failed to insert claim: %w", err)
}
id, err := res.LastInsertId()
if err != nil {
audit.err = err
span.RecordError(err)
span.SetStatus(codes.Error, "failed to last insert id")
return 0, fmt.Errorf("failed to get last ID: %w", err)
}
@ -141,25 +177,42 @@ func (s *Store) Claim(ctx context.Context, userId, player, province string, clai
}
func (s *Store) ListAvailability(ctx context.Context, claimType ClaimType, search ...string) ([]string, error) {
log.Debug().Ctx(ctx).Stringer("claim_type", claimType).Strs("search_terms", search).Msg("listing available entries")
ctx, span := tracer.Start(ctx, "list_availability", trace.WithAttributes(
semconv.DBSystemSqlite,
semconv.DBSQLTable("claimables"),
semconv.DBOperation("select"),
attribute.StringSlice("search_terms", search),
attribute.Stringer("claim_type", claimType),
))
defer span.End()
s.logger.Debug().Ctx(ctx).Stringer("claim_type", claimType).Strs("search_terms", search).Msg("listing available entries")
queryParams := []any{string(claimType)}
queryPattern := `SELECT DISTINCT(provinces.%[1]s)
FROM provinces LEFT JOIN claims ON provinces.%[1]s = claims.val AND claims.claim_type = ?
queryPattern := `SELECT distinct name
FROM claimables
LEFT JOIN claims ON claimables.name = claims.val AND claimables.typ = claims.claim_type
WHERE claims.val IS NULL
AND provinces.typ = 'Land'`
AND claimables.typ = ?`
if len(search) > 0 && search[0] != "" {
// only take one search param, ignore the rest
queryPattern += `AND provinces.%[1]s LIKE ?`
queryPattern += `AND claimables.name LIKE ?`
queryParams = append(queryParams, fmt.Sprintf("%%%s%%", search[0]))
}
stmt, err := s.db.PrepareContext(ctx, fmt.Sprintf(queryPattern, claimTypeToColumn[claimType]))
stmt, err := s.db.PrepareContext(ctx, queryPattern)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to prepare query")
return nil, fmt.Errorf("failed to prepare query: %w", err)
}
defer stmt.Close()
rows, err := stmt.QueryContext(ctx, queryParams...)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to execute query")
return nil, fmt.Errorf("failed to execute query: %w", err)
}
@ -167,6 +220,8 @@ func (s *Store) ListAvailability(ctx context.Context, claimType ClaimType, searc
for rows.Next() {
var s string
if err := rows.Scan(&s); err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to scan row")
return nil, fmt.Errorf("failed to scan rows: %w", err)
}
avail = append(avail, s)
@ -176,14 +231,26 @@ func (s *Store) ListAvailability(ctx context.Context, claimType ClaimType, searc
}
func (s *Store) ListClaims(ctx context.Context) ([]Claim, error) {
log.Debug().Ctx(ctx).Msg("listing all claims currently in database")
ctx, span := tracer.Start(ctx, "list_claims", trace.WithAttributes(
semconv.DBSystemSqlite,
semconv.DBSQLTable("claims"),
semconv.DBOperation("select"),
))
defer span.End()
s.logger.Debug().Ctx(ctx).Msg("listing all claims currently in database")
stmt, err := s.db.PrepareContext(ctx, `SELECT id, player, claim_type, val FROM claims`)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to prepare query")
return nil, fmt.Errorf("failed to prepare query: %w", err)
}
defer stmt.Close()
rows, err := stmt.QueryContext(ctx)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to execute query")
return nil, fmt.Errorf("failed to execute query: %w", err)
}
@ -193,10 +260,14 @@ func (s *Store) ListClaims(ctx context.Context) ([]Claim, error) {
var rawType string
err = rows.Scan(&c.ID, &c.Player, &rawType, &c.Name)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to scan row")
return nil, fmt.Errorf("failed to scan row: %w", err)
}
cl, err := ClaimTypeFromString(rawType)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to parse claim type")
return nil, fmt.Errorf("unexpected error converting raw claim type: %w", err)
}
c.Type = cl
@ -222,11 +293,22 @@ func (cd ClaimDetail) String() string {
}
func (s *Store) DescribeClaim(ctx context.Context, ID int) (ClaimDetail, error) {
log.Debug().Ctx(ctx).Int("id", ID).Msg("describing claim")
ctx, span := tracer.Start(ctx, "describe_claim", trace.WithAttributes(
semconv.DBSystemSqlite,
semconv.DBSQLTable("claims"),
semconv.DBOperation("select"),
attribute.Int("claim_id", ID),
))
defer span.End()
s.logger.Debug().Ctx(ctx).Int("id", ID).Msg("describing claim")
stmt, err := s.db.PrepareContext(ctx, `SELECT id, player, claim_type, val FROM claims WHERE id = ?`)
if err != nil {
return ClaimDetail{}, fmt.Errorf("failed to get claim: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, "failed to prepare query")
return ClaimDetail{}, fmt.Errorf("failed to prepare select claim query: %w", err)
}
defer stmt.Close()
row := stmt.QueryRowContext(ctx, ID)
@ -234,24 +316,34 @@ func (s *Store) DescribeClaim(ctx context.Context, ID int) (ClaimDetail, error)
var rawType string
err = row.Scan(&c.ID, &c.Player, &rawType, &c.Name)
if err == sql.ErrNoRows {
span.RecordError(ErrNoSuchClaim)
return ClaimDetail{}, ErrNoSuchClaim
}
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to scan row")
return ClaimDetail{}, fmt.Errorf("failed to scan row: %w", err)
}
cl, err := ClaimTypeFromString(rawType)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to parse claim type")
return ClaimDetail{}, fmt.Errorf("unexpected error converting raw claim type: %w", err)
}
c.Type = cl
stmt, err = s.db.PrepareContext(ctx, fmt.Sprintf(`SELECT name FROM provinces where provinces.%s = ?`, claimTypeToColumn[cl]))
stmt, err = s.db.PrepareContext(ctx, `SELECT province FROM claimables WHERE name = ? AND typ = ?`)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to prepare query")
return ClaimDetail{}, fmt.Errorf("failed to prepare query: %w", err)
}
defer stmt.Close()
rows, err := stmt.QueryContext(ctx, c.Name)
rows, err := stmt.QueryContext(ctx, c.Name, cl)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to execute query")
return ClaimDetail{}, fmt.Errorf("failed to execute query: %w", err)
}
@ -260,6 +352,8 @@ func (s *Store) DescribeClaim(ctx context.Context, ID int) (ClaimDetail, error)
var p string
err = rows.Scan(&p)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to scan row")
return ClaimDetail{}, fmt.Errorf("failed to scan result set: %w", err)
}
provinces = append(provinces, p)
@ -272,7 +366,16 @@ func (s *Store) DescribeClaim(ctx context.Context, ID int) (ClaimDetail, error)
}
func (s *Store) DeleteClaim(ctx context.Context, ID int, userId string) error {
log.Debug().Ctx(ctx).Str("userid", userId).Int("id", ID).Msg("deleting claim")
ctx, span := tracer.Start(ctx, "delete_claim", trace.WithAttributes(
semconv.DBSystemSqlite,
semconv.DBSQLTable("claims"),
semconv.DBOperation("delete"),
attribute.Int("claim_id", ID),
attribute.String("user_id", userId),
))
defer span.End()
s.logger.Debug().Ctx(ctx).Str("userid", userId).Int("id", ID).Msg("deleting claim")
audit := &AuditableEvent{
userId: userId,
eventType: EventUnclaim,
@ -282,37 +385,57 @@ func (s *Store) DeleteClaim(ctx context.Context, ID int, userId string) error {
stmt, err := s.db.PrepareContext(ctx, "DELETE FROM claims WHERE id = ? AND userid = ?")
if err != nil {
audit.err = err
span.RecordError(err)
span.SetStatus(codes.Error, "failed to prepare query")
return fmt.Errorf("failed to prepare query: %w", err)
}
defer stmt.Close()
res, err := stmt.ExecContext(ctx, ID, userId)
if err != nil {
audit.err = err
span.RecordError(err)
span.SetStatus(codes.Error, "failed to execute query")
return fmt.Errorf("failed to delete claim ID %d: %w", ID, err)
}
rows, err := res.RowsAffected()
if err != nil {
audit.err = err
span.RecordError(err)
span.SetStatus(codes.Error, "failed to get number of affected rows")
return fmt.Errorf("failed to get affected rows: %w", err)
}
if rows == 0 {
audit.err = ErrNoSuchClaim
span.RecordError(ErrNoSuchClaim)
return ErrNoSuchClaim
}
return nil
}
func (s *Store) CountClaims(ctx context.Context) (total, uniquePlayers int, err error) {
log.Debug().Ctx(ctx).Msg("counting all claims and unique users")
ctx, span := tracer.Start(ctx, "count_claims", trace.WithAttributes(
semconv.DBSystemSqlite,
semconv.DBSQLTable("claims"),
semconv.DBOperation("select"),
))
defer span.End()
s.logger.Debug().Ctx(ctx).Msg("counting all claims and unique users")
stmt, err := s.db.PrepareContext(ctx, "SELECT COUNT(1), COUNT(DISTINCT(userid)) FROM claims")
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to prepare query")
return 0, 0, fmt.Errorf("failed to prepare query: %w", err)
}
defer stmt.Close()
res := stmt.QueryRowContext(ctx)
if err := res.Scan(&total, &uniquePlayers); err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "failed to scan row")
return 0, 0, fmt.Errorf("failed to scan result: %w", err)
}
@ -320,7 +443,14 @@ func (s *Store) CountClaims(ctx context.Context) (total, uniquePlayers int, err
}
func (s *Store) Flush(ctx context.Context, userId string) error {
log.Debug().Ctx(ctx).Str("initiated_by", userId).Msg("flushing all currently help claims")
ctx, span := tracer.Start(ctx, "flush", trace.WithAttributes(
semconv.DBSystemSqlite,
semconv.DBSQLTable("claims"),
semconv.DBOperation("delete"),
))
defer span.End()
s.logger.Debug().Ctx(ctx).Str("initiated_by", userId).Msg("flushing all currently help claims")
audit := &AuditableEvent{
userId: userId,
eventType: EventFlush,
@ -330,6 +460,8 @@ func (s *Store) Flush(ctx context.Context, userId string) error {
_, err := s.db.ExecContext(ctx, "DELETE FROM claims;")
if err != nil {
audit.err = err
span.RecordError(err)
span.SetStatus(codes.Error, "failed to execute query")
return fmt.Errorf("failed to execute delete query: %w", err)
}
return nil

@ -3,11 +3,13 @@ package themis
import (
"context"
"database/sql"
_ "embed"
"fmt"
"testing"
_ "github.com/mattn/go-sqlite3"
"github.com/rs/zerolog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@ -15,7 +17,9 @@ import (
const TEST_CONN_STRING_PATTERN = "file:%s?mode=memory&cache=shared"
func TestStore_Claim(t *testing.T) {
store, err := NewStore(fmt.Sprintf(TEST_CONN_STRING_PATTERN, "TestStore_Claim"))
db, err := sql.Open("sqlite3", fmt.Sprintf(TEST_CONN_STRING_PATTERN, "TestStore_Claim"))
require.NoError(t, err)
store, err := NewStore(db, zerolog.Nop())
require.NoError(t, err)
type args struct {
@ -97,16 +101,20 @@ func TestStore_Claim(t *testing.T) {
t.Errorf("Store.Claim() error = %v, wantErr %v", err, tt.wantErr)
}
if !tt.wantErr {
ae, err := store.LastOf(context.TODO(), EventClaim)
require.NoError(t, err)
assert.Greater(t, ae.Id, lastAudit)
lastAudit = ae.Id
}
})
}
}
func TestAvailability(t *testing.T) {
store, err := NewStore(fmt.Sprintf(TEST_CONN_STRING_PATTERN, "TestAvailability"))
db, err := sql.Open("sqlite3", fmt.Sprintf(TEST_CONN_STRING_PATTERN, "TestStore_Availability"))
require.NoError(t, err)
store, err := NewStore(db, zerolog.Nop())
assert.NoError(t, err)
store.Claim(context.TODO(), "000000000000000001", "foo", "Genoa", CLAIM_TYPE_TRADE)
@ -122,22 +130,22 @@ func TestAvailability(t *testing.T) {
store.Claim(context.TODO(), "000000000000000001", "foo", "France", CLAIM_TYPE_REGION)
store.Claim(context.TODO(), "000000000000000001", "foo", "Italy", CLAIM_TYPE_REGION)
// There's a total of 73 distinct regions, there should be 71 available
// There's a total of 92 distinct regions, there should be 90 available
// after the two claims above
availability, err = store.ListAvailability(context.TODO(), CLAIM_TYPE_REGION)
assert.NoError(t, err)
assert.Equal(t, 71, len(availability))
assert.Equal(t, 90, len(availability))
store.Claim(context.TODO(), "000000000000000001", "foo", "Normandy", CLAIM_TYPE_AREA)
store.Claim(context.TODO(), "000000000000000001", "foo", "Champagne", CLAIM_TYPE_AREA)
store.Claim(context.TODO(), "000000000000000001", "foo", "Lorraine", CLAIM_TYPE_AREA)
store.Claim(context.TODO(), "000000000000000001", "foo", "Provence", CLAIM_TYPE_AREA)
// There's a total of 823 distinct regions, there should be 819 available
// There's a total of 882 distinct regions, there should be 878 available
// after the four claims above
availability, err = store.ListAvailability(context.TODO(), CLAIM_TYPE_AREA)
assert.NoError(t, err)
assert.Equal(t, 819, len(availability))
assert.Equal(t, 878, len(availability))
// There is both a Trade Node and an Area called 'Valencia', while the trade
// node is claimed, the area should show up in the availability list (even
@ -145,15 +153,17 @@ func TestAvailability(t *testing.T) {
store.Claim(context.TODO(), "000000000000000001", "foo", "Valencia", CLAIM_TYPE_TRADE)
availability, err = store.ListAvailability(context.TODO(), CLAIM_TYPE_AREA)
assert.NoError(t, err)
assert.Equal(t, 819, len(availability)) // availability for areas should be the same as before
assert.Equal(t, 878, len(availability)) // availability for areas should be the same as before
availability, err = store.ListAvailability(context.TODO(), CLAIM_TYPE_AREA, "bay")
assert.NoError(t, err)
assert.Equal(t, 3, len(availability)) // availability for areas should be the same as before
assert.Equal(t, 6, len(availability)) // availability for areas should be the same as before
}
func TestDeleteClaim(t *testing.T) {
store, err := NewStore(fmt.Sprintf(TEST_CONN_STRING_PATTERN, "TestDeleteClaim"))
db, err := sql.Open("sqlite3", fmt.Sprintf(TEST_CONN_STRING_PATTERN, "TestStore_DeleteClaim"))
require.NoError(t, err)
store, err := NewStore(db, zerolog.Nop())
assert.NoError(t, err)
// make sure all claims are gone, this is due to how the in-memory database
// with a shared cache interacts with other tests running in parallel
@ -185,7 +195,9 @@ func TestDeleteClaim(t *testing.T) {
}
func TestDescribeClaim(t *testing.T) {
store, err := NewStore(fmt.Sprintf(TEST_CONN_STRING_PATTERN, "TestDescribeClaim"))
db, err := sql.Open("sqlite3", fmt.Sprintf(TEST_CONN_STRING_PATTERN, "TestStore_DescribeClaim"))
require.NoError(t, err)
store, err := NewStore(db, zerolog.Nop())
assert.NoError(t, err)
id, err := store.Claim(context.TODO(), "000000000000000001", "foo", "Genoa", CLAIM_TYPE_TRADE)
@ -201,7 +213,9 @@ func TestDescribeClaim(t *testing.T) {
}
func TestCountClaims(t *testing.T) {
store, err := NewStore(fmt.Sprintf(TEST_CONN_STRING_PATTERN, "TestCountClaims"))
db, err := sql.Open("sqlite3", fmt.Sprintf(TEST_CONN_STRING_PATTERN, "TestStore_CountClaim"))
require.NoError(t, err)
store, err := NewStore(db, zerolog.Nop())
assert.NoError(t, err)
store.Claim(context.TODO(), "000000000000000001", "foo", "Genoa", CLAIM_TYPE_TRADE)
@ -217,7 +231,9 @@ func TestCountClaims(t *testing.T) {
}
func TestFlush(t *testing.T) {
store, err := NewStore(fmt.Sprintf(TEST_CONN_STRING_PATTERN, "TestFlush"))
db, err := sql.Open("sqlite3", fmt.Sprintf(TEST_CONN_STRING_PATTERN, "TestStore_Flush"))
require.NoError(t, err)
store, err := NewStore(db, zerolog.Nop())
assert.NoError(t, err)
store.Claim(context.TODO(), "000000000000000001", "foo", "Genoa", CLAIM_TYPE_TRADE)

@ -3,10 +3,11 @@
package themis
import (
"context"
"time"
)
// Uptime returns the time elapsed since the start of the current process ID.
func Uptime() (time.Duration, error) {
func Uptime(ctx context.Context) (time.Duration, error) {
return 0, nil
}

Loading…
Cancel
Save