Skip to content
This repository has been archived by the owner on Jul 12, 2023. It is now read-only.

Add per realm stats #446

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/server/assets/header.html
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ <h6 class="dropdown-header">Manage realm</h6>
<a class="dropdown-item" href="/apikeys">API keys</a>
<a class="dropdown-item" href="/realm/keys">Signing keys</a>
<a class="dropdown-item" href="/users">Users</a>
<a class="dropdown-item" href="/realm/stats">Statistics</a>
jeremyfaller marked this conversation as resolved.
Show resolved Hide resolved
<a class="dropdown-item" href="/realm/settings">Settings</a>
<div class="dropdown-divider"></div>
{{end}}
Expand Down
64 changes: 64 additions & 0 deletions cmd/server/assets/realmstats.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
{{define "realmstats"}}

{{$realm := .realm}}
{{$stats := .stats}}

<!doctype html>
<html lang="en">
<head>
{{template "head" .}}
</head>

<body>
{{template "navbar" .}}

<main role="main" class="container">
{{template "flash" .}}

<h1>Realm stats</h1>

<div class="card mb-3">
<div class="card-header">Statistics</div>
<div class="card-body table-responsive">
{{if $stats}}
<div id="chart" style="height: 300px;">
<span>Loading chart...</span>
</div>
{{else}}
<p>No codes have been issued in this realm.</p>
{{end}}
</div>
</div>

</main>

{{template "scripts" .}}

{{if $stats}}
<script src="https://www.gstatic.com/charts/loader.js"></script>
<script>
google.charts.load('current', {packages: ['line']});
google.charts.setOnLoadCallback(drawChart)

function drawChart() {
var data = google.visualization.arrayToDataTable([
['Date', 'Issued', "Claimed"],
{{range $stat := $stats}}
['{{$stat.Date.Format "Jan 2"}}', {{$stat.CodesIssued}}, {{$stat.CodesClaimed}}],
{{end}}
]);

var options = {
colors: ['#007bff', '#ff7b00'],
legend: {position: 'left'},
tooltip: {trigger: 'focus'},
};

var chart = new google.charts.Line(document.getElementById('chart'));
chart.draw(data, options);
}
</script>
{{end}}
</body>
</html>
{{end}}
4 changes: 4 additions & 0 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import (
"github.com/google/exposure-notifications-verification-server/pkg/controller/realm"
"github.com/google/exposure-notifications-verification-server/pkg/controller/realmadmin"
"github.com/google/exposure-notifications-verification-server/pkg/controller/realmkeys"
"github.com/google/exposure-notifications-verification-server/pkg/controller/realmstats"
"github.com/google/exposure-notifications-verification-server/pkg/controller/user"
"github.com/google/exposure-notifications-verification-server/pkg/ratelimit"
"github.com/google/exposure-notifications-verification-server/pkg/ratelimit/limitware"
Expand Down Expand Up @@ -317,6 +318,9 @@ func realMain(ctx context.Context) error {
realmSub.Handle("/settings/enable-express", realmadminController.HandleEnableExpress()).Methods("POST")
realmSub.Handle("/settings/disable-express", realmadminController.HandleDisableExpress()).Methods("POST")

realmStatsController := realmstats.New(ctx, db, h)
realmSub.Handle("/stats", realmStatsController.HandleIndex()).Methods("GET")

realmKeysController, err := realmkeys.New(ctx, config, db, certificateSigner, h)
if err != nil {
return fmt.Errorf("failed to create realmkeys controller: %w", err)
Expand Down
55 changes: 55 additions & 0 deletions pkg/controller/realmstats/index.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Copyright 2020 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package realmstats

import (
"context"
"net/http"
"time"

"github.com/google/exposure-notifications-verification-server/pkg/controller"
"github.com/google/exposure-notifications-verification-server/pkg/database"
)

func (c *Controller) HandleIndex() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()

realm := controller.RealmFromContext(ctx)
if realm == nil {
controller.MissingRealm(w, r, c.h)
return
}

today := time.Now().Truncate(24 * time.Hour)
nago := today.Add(-30 * 24 * time.Hour).Truncate(24 * time.Hour)
stats, err := realm.Stats(c.db, nago, today)
if err != nil {
controller.InternalError(w, r, c.h, err)
return
}

c.renderShow(ctx, w, realm, stats)
})
}

func (c *Controller) renderShow(ctx context.Context, w http.ResponseWriter, realm *database.Realm, stats []*database.RealmStats) {
m := controller.TemplateMapFromContext(ctx)
m["realm"] = realm
m["stats"] = stats

// Valid settings for code parameters.
c.h.RenderHTML(w, "realmstats", m)
}
43 changes: 43 additions & 0 deletions pkg/controller/realmstats/realmstats.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Copyright 2020 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// Package realmstats contains web controllers for changing realm stats.
package realmstats

import (
"context"

"github.com/google/exposure-notifications-verification-server/pkg/database"
"github.com/google/exposure-notifications-verification-server/pkg/render"

"github.com/google/exposure-notifications-server/pkg/logging"

"go.uber.org/zap"
)

type Controller struct {
db *database.Database
h *render.Renderer
logger *zap.SugaredLogger
}

func New(ctx context.Context, db *database.Database, h *render.Renderer) *Controller {
logger := logging.FromContext(ctx).Named("realmstats")

return &Controller{
db: db,
h: h,
logger: logger,
}
}
25 changes: 25 additions & 0 deletions pkg/database/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -863,6 +863,31 @@ func (db *Database) getMigrations(ctx context.Context) *gormigrate.Gormigrate {
return nil
},
},
{
ID: "00035-AddRealmStats",
Migrate: func(tx *gorm.DB) error {
logger.Debugw("db migrations: adding realm stats")
if err := tx.AutoMigrate(&RealmStats{}).Error; err != nil {
return err
}
statements := []string{
"CREATE UNIQUE INDEX IF NOT EXISTS idx_realm_stats_stats_date_realm_id ON realm_stats (date, realm_id)",
"CREATE INDEX IF NOT EXISTS idx_realm_stats_date ON realm_stats (date)",
}
for _, sql := range statements {
if err := tx.Exec(sql).Error; err != nil {
return err
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
if err := tx.DropTable(&RealmStats{}).Error; err != nil {
return err
}
return nil
},
},
})
}

Expand Down
24 changes: 24 additions & 0 deletions pkg/database/realm.go
Original file line number Diff line number Diff line change
Expand Up @@ -608,3 +608,27 @@ func (r *Realm) DestroySigningKeyVersion(ctx context.Context, db *Database, id i

return nil
}

// Stats returns the usage statistics for this realm. If no stats exist,
// returns an empty array.
func (r *Realm) Stats(db *Database, start, stop time.Time) ([]*RealmStats, error) {
var stats []*RealmStats

start = start.Truncate(24 * time.Hour)
stop = stop.Truncate(24 * time.Hour)

if err := db.db.
Model(&RealmStats{}).
Where("realm_id = ?", r.ID).
Where("(date >= ? AND date <= ?)", start, stop).
Order("date ASC").
Find(&stats).
Error; err != nil {
if IsNotFound(err) {
return stats, nil
}
return nil, err
}

return stats, nil
}
32 changes: 32 additions & 0 deletions pkg/database/realm_stats.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Copyright 2020 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package database

import (
"time"
)

// RealmStats represents statistics related to a user in the database.
type RealmStats struct {
Date time.Time `gorm:"date; not null"`
RealmID uint `gorm:"realm_id; not null"`
CodesIssued uint `gorm:"codes_issued; default: 0"`
CodesClaimed uint `gorm:"codes_claimed; default: 0"`
}

// TableName sets the RealmStats table name
func (RealmStats) TableName() string {
return "realm_stats"
}
17 changes: 5 additions & 12 deletions pkg/database/token.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,23 +183,16 @@ func (db *Database) VerifyCodeAndIssueToken(realmID uint, verCode string, accept
return err
}

if expired, err := db.IsCodeExpired(&vc, verCode); err != nil {
db.logger.Debugw("error checking code expiration", "error", err)
return ErrVerificationCodeExpired
} else if expired {
return ErrVerificationCodeExpired
}

if vc.Claimed {
return ErrVerificationCodeUsed
}

if _, ok := acceptTypes[vc.TestType]; !ok {
return ErrUnsupportedTestType
}

// And mark as claimed.
if err := vc.claim(db, tx, verCode, realmID); err != nil {
return err
}

// Mark claimed. Transactional update.
vc.Claimed = true
if err := tx.Save(&vc).Error; err != nil {
return err
}
Expand Down
47 changes: 43 additions & 4 deletions pkg/database/vercode.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ func (VerificationCode) TableName() string {
// to update statistics about usage. If the executions fail, an error is logged
// but the transaction continues. This is called automatically by gorm.
func (v *VerificationCode) AfterCreate(scope *gorm.Scope) {
date := v.CreatedAt.Truncate(24 * time.Hour)
// If the issuer was a user, update the user stats for the day.
if v.IssuingUserID != 0 {
sql := `
Expand All @@ -82,8 +83,7 @@ func (v *VerificationCode) AfterCreate(scope *gorm.Scope) {
SET codes_issued = user_stats.codes_issued + 1
`

day := time.Now().UTC().Truncate(24 * time.Hour)
if err := scope.DB().Exec(sql, day, v.RealmID, v.IssuingUserID).Error; err != nil {
if err := scope.DB().Exec(sql, date, v.RealmID, v.IssuingUserID).Error; err != nil {
scope.Log(fmt.Sprintf("failed to update stats: %v", err))
}
}
Expand All @@ -97,11 +97,50 @@ func (v *VerificationCode) AfterCreate(scope *gorm.Scope) {
SET codes_issued = authorized_app_stats.codes_issued + 1
`

day := time.Now().UTC().Truncate(24 * time.Hour)
if err := scope.DB().Exec(sql, day, v.IssuingAppID).Error; err != nil {
if err := scope.DB().Exec(sql, date, v.IssuingAppID).Error; err != nil {
scope.Log(fmt.Sprintf("failed to update stats: %v", err))
}
}

// Update the per-realm stats.
sql := `
jeremyfaller marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably guard this with a sanity:

if v.RealmID != 0 {

can be a follow-up

INSERT INTO realm_stats(date, realm_id, codes_issued)
VALUES ($1, $2, 1)
ON CONFLICT (date, realm_id) DO UPDATE
SET codes_issued = realm_stats.codes_issued + 1
`

if err := scope.DB().Exec(sql, date, v.RealmID).Error; err != nil {
scope.Log(fmt.Sprintf("failed to update stats: %v", err))
}
}

// claim checks the VerificationCode, marks as claimed, and updates the
// per-realm stats. If any of that fails, an error code is returned.
func (v *VerificationCode) claim(db *Database, tx *gorm.DB, verCode string, realmID uint) error {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This API is a little weird to me now, and I'm not sure the comment matches. While this sets Claimed: true on the Verification Code, it does not save that code in the database. The caller still needs to call db.Save(v). That feels subtle and could lead to some bugs. Furthermore, we're updating the realm stats before successfully marking the code as claimed. I'm thinking this flow should be more like:

  1. Expiration checks
  2. Claimed check
  3. Mark as claimed on struct
  4. Save struct to database (stop if error)
  5. Update stats
  6. Return

Alternatively, we need to document this better. What do you think?

if expired, err := db.IsCodeExpired(v, verCode); err != nil {
return err
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It'd be nice to wrap this error like fmt.Errorf("failed to claim code: %w")

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can be a follow-up

} else if expired {
return ErrVerificationCodeExpired
}

if v.Claimed {
return ErrVerificationCodeUsed
}

now := time.Now().Truncate(24 * time.Hour)
sql := `
INSERT INTO realm_stats(date, realm_id, codes_claimed)
VALUES ($1, $2, 1)
ON CONFLICT (date, realm_id) DO UPDATE
SET codes_claimed = realm_stats.codes_claimed + 1
`
if err := tx.Exec(sql, now, v.RealmID).Error; err != nil {
return err
}

v.Claimed = true
return nil
}

// TODO(mikehelmick) - Add method to soft delete expired codes
Expand Down