status-go/appmetrics/database.go
Samuel Hawksby-Robinson 07e46714f0
Anon Metrics Broadcast (#2198)
* Protobufs and adapters

* Added basic anon metric service and config init

* Added fibonacci interval incrementer

* Added basic Client.Start func and integrated interval incrementer

* Added new processed field to app metrics table

* Added id column to app metrics table

* Added migration clean up

* Added appmetrics GetUnprocessed and SetToProcessedByIDs and tests

There was a wierd bug where metrics in the db that did not explicitly insert a  value would be NULL, so could not be found by . In addition I've added a new primary id field to the app_metrics table so that updates could be done against very specific metric rows.

* Updated adaptors and db to handle proto_id

I need a way to distinguish individual metric items from each other so that I can ignore the ones that have been seen before.

* Moved incrementer into dedicated file

* Resolve incrementer test fail

* Finalised the main loop functionality

* Implemented delete loop framework

* Updated adaptors file name

* Added delete loop delay and quit, and tweak on RawMessage gen

* Completed delete loop logic

* Added DBLock to prevent deletion during mainLoop

* Added postgres DB connection, integrated into anonmetrics.Server

* Removed proto_id from SQL migration and model

* Integrated postgres with Server and updated adaptors

* Function name update

* Added sample config files for client and server

* Fixes and testing for low level e2e

* make generate

* Fix lint

* Fix for receiving an anonMetricBatch not in server mode

* Postgres test fixes

* Tidy up, make vendor and make generate

* delinting

* Fixing database tests

* Attempted fix of does:  cannot open `does' (No such file or directory)
not:   cannot open `not' (No such file or directory)
exist: cannot open `exist' (No such file or directory) error on sql resource loas

* Moved all anon metric postgres migration logic and sources into a the protocol/anonmetrics package or sub packages. I don't know if this will fix the does:  cannot open `does' (No such file or directory)
not:   cannot open `not' (No such file or directory)
exist: cannot open `exist' (No such file or directory) error that happens in Jenkins but this could work

* Lint for the lint god

* Why doesn't the linter list all its problems at once?

* test tweaks

* Fix for wakuV2 change

* DB reset change

* Fix for postgres db migrations fails

* More robust implementation of postgres test setup and teardown

* Added block for anon metrics functionality

* Version Bump to 0.84.0

* Added test to check anon metrics broadcast is deactivated

* Protobufs and adapters

* Added basic anon metric service and config init

* Added new processed field to app metrics table

* Added id column to app metrics table

* Added migration clean up

* Added appmetrics GetUnprocessed and SetToProcessedByIDs and tests

There was a wierd bug where metrics in the db that did not explicitly insert a  value would be NULL, so could not be found by . In addition I've added a new primary id field to the app_metrics table so that updates could be done against very specific metric rows.

* Updated adaptors and db to handle proto_id

I need a way to distinguish individual metric items from each other so that I can ignore the ones that have been seen before.

* Added postgres DB connection, integrated into anonmetrics.Server

* Removed proto_id from SQL migration and model

* Integrated postgres with Server and updated adaptors

* Added sample config files for client and server

* Fix lint

* Fix for receiving an anonMetricBatch not in server mode

* Postgres test fixes

* Tidy up, make vendor and make generate

* Moved all anon metric postgres migration logic and sources into a the protocol/anonmetrics package or sub packages. I don't know if this will fix the does:  cannot open `does' (No such file or directory)
not:   cannot open `not' (No such file or directory)
exist: cannot open `exist' (No such file or directory) error that happens in Jenkins but this could work
2021-09-01 13:02:18 +01:00

316 lines
7.0 KiB
Go

package appmetrics
import (
"database/sql"
"encoding/json"
"errors"
"strings"
"time"
"github.com/xeipuuv/gojsonschema"
)
type AppMetricEventType string
// Value is `json.RawMessage` so we can send any json shape, including strings
// Validation is handled using JSON schemas defined in validators.go, instead of Golang structs
type AppMetric struct {
ID int `json:"-"`
MessageID string `json:"message_id"`
Event AppMetricEventType `json:"event"`
Value json.RawMessage `json:"value"`
AppVersion string `json:"app_version"`
OS string `json:"os"`
SessionID string `json:"session_id"`
CreatedAt time.Time `json:"created_at"`
Processed bool `json:"processed"`
ReceivedAt time.Time `json:"received_at"`
}
type AppMetricValidationError struct {
Metric AppMetric
Errors []gojsonschema.ResultError
}
type Page struct {
AppMetrics []AppMetric
TotalCount int
}
const (
// status-react navigation events
NavigateTo AppMetricEventType = "navigate-to"
ScreensOnWillFocus AppMetricEventType = "screens/on-will-focus"
)
// EventSchemaMap Every event should have a schema attached
var EventSchemaMap = map[AppMetricEventType]interface{}{
NavigateTo: NavigateToCofxSchema,
ScreensOnWillFocus: NavigateToCofxSchema,
}
func NewDB(db *sql.DB) *Database {
return &Database{db: db}
}
// Database sql wrapper for operations with browser objects.
type Database struct {
db *sql.DB
}
// Close closes database.
func (db Database) Close() error {
return db.db.Close()
}
func jsonschemaErrorsToError(validationErrors []AppMetricValidationError) error {
var fieldErrors []string
for _, appMetricValidationError := range validationErrors {
metric := appMetricValidationError.Metric
errors := appMetricValidationError.Errors
var errorDesc string = "Error in event: " + string(metric.Event) + " - "
for _, e := range errors {
errorDesc = errorDesc + "value." + e.Context().String() + ":" + e.Description()
}
fieldErrors = append(fieldErrors, errorDesc)
}
return errors.New(strings.Join(fieldErrors[:], "/ "))
}
func (db *Database) ValidateAppMetrics(appMetrics []AppMetric) (err error) {
var calculatedErrors []AppMetricValidationError
for _, metric := range appMetrics {
schema := EventSchemaMap[metric.Event]
if schema == nil {
return errors.New("No schema defined for: " + string(metric.Event))
}
schemaLoader := gojsonschema.NewGoLoader(schema)
valLoader := gojsonschema.NewStringLoader(string(metric.Value))
res, err := gojsonschema.Validate(schemaLoader, valLoader)
if err != nil {
return err
}
// validate all metrics and save errors
if !res.Valid() {
calculatedErrors = append(calculatedErrors, AppMetricValidationError{metric, res.Errors()})
}
}
if len(calculatedErrors) > 0 {
return jsonschemaErrorsToError(calculatedErrors)
}
return
}
func (db *Database) SaveAppMetrics(appMetrics []AppMetric, sessionID string) (err error) {
var (
tx *sql.Tx
insert *sql.Stmt
)
// make sure that the shape of the metric is same as expected
err = db.ValidateAppMetrics(appMetrics)
if err != nil {
return err
}
// start txn
tx, err = db.db.Begin()
if err != nil {
return err
}
defer func() {
if err == nil {
err = tx.Commit()
return
}
_ = tx.Rollback()
}()
insert, err = tx.Prepare("INSERT INTO app_metrics (event, value, app_version, operating_system, session_id, processed) VALUES (?, ?, ?, ?, ?, ?)")
if err != nil {
return err
}
for _, metric := range appMetrics {
_, err = insert.Exec(metric.Event, metric.Value, metric.AppVersion, metric.OS, sessionID, metric.Processed)
if err != nil {
return
}
}
return
}
func (db *Database) GetAppMetrics(limit int, offset int) (page Page, err error) {
countErr := db.db.QueryRow("SELECT count(*) FROM app_metrics").Scan(&page.TotalCount)
if countErr != nil {
return page, countErr
}
rows, err := db.db.Query("SELECT id, event, value, app_version, operating_system, session_id, created_at, processed FROM app_metrics LIMIT ? OFFSET ?", limit, offset)
if err != nil {
return page, err
}
defer rows.Close()
page.AppMetrics, err = db.getFromRows(rows)
return page, err
}
func (db *Database) getFromRows(rows *sql.Rows) (appMetrics []AppMetric, err error) {
var metrics []AppMetric
for rows.Next() {
metric := AppMetric{}
err = rows.Scan(
&metric.ID,
&metric.Event,
&metric.Value,
&metric.AppVersion,
&metric.OS,
&metric.SessionID,
&metric.CreatedAt,
&metric.Processed,
)
if err != nil {
return metrics, err
}
metrics = append(metrics, metric)
}
return metrics, nil
}
func (db *Database) GetUnprocessed() ([]AppMetric, error) {
rows, err := db.db.Query("SELECT id, event, value, app_version, operating_system, session_id, created_at, processed FROM app_metrics WHERE processed IS ? ORDER BY session_id ASC, created_at ASC", false)
if err != nil {
return nil, err
}
defer rows.Close()
return db.getFromRows(rows)
}
func (db *Database) GetUnprocessedGroupedBySession() (map[string][]AppMetric, error) {
uam, err := db.GetUnprocessed()
if err != nil {
return nil, err
}
out := map[string][]AppMetric{}
for _, am := range uam {
out[am.SessionID] = append(out[am.SessionID], am)
}
return out, nil
}
func (db *Database) SetToProcessedByIDs(ids []int) (err error) {
var (
tx *sql.Tx
update *sql.Stmt
)
// start txn
tx, err = db.db.Begin()
if err != nil {
return err
}
defer func() {
if err == nil {
err = tx.Commit()
return
}
_ = tx.Rollback()
}()
// Generate prepared statement IN list
in := "("
for i := 0; i < len(ids); i++ {
in += "?,"
}
in = in[:len(in)-1] + ")"
update, err = tx.Prepare("UPDATE app_metrics SET processed = 1 WHERE id IN " + in)
if err != nil {
return err
}
// Convert the ids into Stmt.Exec compatible variadic
args := make([]interface{}, 0, len(ids))
for _, id := range ids {
args = append(args, id)
}
_, err = update.Exec(args...)
if err != nil {
return
}
return
}
func (db *Database) SetToProcessed(appMetrics []AppMetric) (err error) {
ids := GetAppMetricsIDs(appMetrics)
return db.SetToProcessedByIDs(ids)
}
func (db *Database) GetMessagesOlderThan(date *time.Time) ([]AppMetric, error) {
rows, err := db.db.Query("SELECT id, event, value, app_version, operating_system, session_id, created_at, processed FROM app_metrics WHERE created_at < ?", date)
if err != nil {
return nil, err
}
defer rows.Close()
return db.getFromRows(rows)
}
func (db *Database) DeleteOlderThan(date *time.Time) (err error) {
var (
tx *sql.Tx
d *sql.Stmt
)
// start txn
tx, err = db.db.Begin()
if err != nil {
return err
}
defer func() {
if err == nil {
err = tx.Commit()
return
}
_ = tx.Rollback()
}()
d, err = tx.Prepare("DELETE FROM app_metrics WHERE created_at < ?")
if err != nil {
return err
}
_, err = d.Exec(date)
if err != nil {
return
}
return
}
func GetAppMetricsIDs(appMetrics []AppMetric) []int {
var ids []int
for _, am := range appMetrics {
ids = append(ids, am.ID)
}
return ids
}