Repository: google/exposure-notifications-server
Pull request #1251 (2020-12-20 23:39:54)

Metrics
First part of #1250

## Proposed Changes

* Create healthauthoritystats
* On publish, increment hourly counters for publish metrics

TODO: Add an API to retrieve these metrics by the health authority (signed w/ same key as verification certificates)

**Release Note**

```release-note
Statistics are captured on publish for health authorities, including: number of publishes by platform, number of TEKs uploaded, the days from onset to upload, and the age of the oldest TEK in the upload.
```

google-oss-robot: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/google/exposure-notifications-server/pull/1251#" title="Author self-approved">mikehelmick</a>*

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=google%2Fexposure-notifications-server).

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

<details >
Needs approval from an approver in each of these files:

- ~~[OWNERS](https://github.com/google/exposure-notifications-server/blob/main/OWNERS)~~ [mikehelmick]

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->
google-oss-robot: @mikehelmick: The following test **failed**, say `/retest` to rerun all failed tests:

Test name | Commit | Details | Rerun command
--- | --- | --- | ---
pull-en-server-release-unit | 20a4ad2d9821af2b376d31c66b7d908b0e15e5ec | [link](https://oss-prow.knative.dev/view/gs/oss-prow/pr-logs/pull/google_exposure-notifications-server/1251/pull-en-server-release-unit/1340804152434888704) | `/test pull-en-server-release-unit`

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes/test-infra](https://github.com/kubernetes/test-infra/issues/new?title=Prow%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
google-oss-robot: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/google/exposure-notifications-server/pull/1251#" title="Author self-approved">mikehelmick</a>*, *<a href="https://github.com/google/exposure-notifications-server/pull/1251#pullrequestreview-556077365" title="LGTM">whaught</a>*

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=google%2Fexposure-notifications-server).

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

<details >
Needs approval from an approver in each of these files:

- ~~[OWNERS](https://github.com/google/exposure-notifications-server/blob/main/OWNERS)~~ [mikehelmick,whaught]

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->
mikehelmick: all comments addressed
google-oss-robot: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/google/exposure-notifications-server/pull/1251#" title="Author self-approved">mikehelmick</a>*, *<a href="https://github.com/google/exposure-notifications-server/pull/1251#pullrequestreview-556766438" title="LGTM">sethvargo</a>*, *<a href="https://github.com/google/exposure-notifications-server/pull/1251#pullrequestreview-556077365" title="LGTM">whaught</a>*

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=google%2Fexposure-notifications-server).

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

<details >
Needs approval from an approver in each of these files:

- ~~[OWNERS](https://github.com/google/exposure-notifications-server/blob/main/OWNERS)~~ [mikehelmick,sethvargo,whaught]

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->

Patch:
diff --git a/internal/exportimport/import.go b/internal/exportimport/import.go
index c31dfa6..e63c4b4 100644
--- a/internal/exportimport/import.go
+++ b/internal/exportimport/import.go
@@ -45,9 +45,9 @@ type ImportRequest struct {
 }
 
 type ImportResposne struct {
-	insertedKeys uint64
-	revisedKeys  uint64
-	droppedKeys  uint64
+	insertedKeys uint32
+	revisedKeys  uint32
+	droppedKeys  uint32
 }
 
 type SignatureAndKey struct {
@@ -224,9 +224,9 @@ type transformer struct {
 	logger             *zap.SugaredLogger
 }
 
-func (t *transformer) transform(keys []*exportproto.TemporaryExposureKey) ([]*pubmodel.Exposure, uint64) {
+func (t *transformer) transform(keys []*exportproto.TemporaryExposureKey) ([]*pubmodel.Exposure, uint32) {
 	inserts := make([]*pubmodel.Exposure, 0, len(keys))
-	var dropped uint64
+	var dropped uint32
 	for _, k := range keys {
 		exp, err := pubmodel.FromExportKey(k, t.exportImportConfig)
 		if err != nil {
diff --git a/internal/generate/handler.go b/internal/generate/handler.go
index c0ad148..6949a3e 100644
--- a/internal/generate/handler.go
+++ b/internal/generate/handler.go
@@ -170,13 +170,13 @@ func (h *generateHandler) generateKeysInRegion(ctx context.Context, region strin
 			SymptomOnsetInterval: uint32(publish.Keys[intervalIdx].IntervalNumber),
 		}
 
-		exposures, _, err := h.transformer.TransformPublish(ctx, publish, regions, &claims, batchTime)
+		result, err := h.transformer.TransformPublish(ctx, publish, regions, &claims, batchTime)
 		if err != nil {
 			return fmt.Errorf("failed to transform generated exposures: %w", err)
 		}
 
 		n, err := h.database.InsertAndReviseExposures(ctx, &publishdb.InsertAndReviseExposuresRequest{
-			Incoming:     exposures,
+			Incoming:     result.Exposures,
 			RequireToken: true,
 		})
 		if err != nil {
@@ -193,14 +193,14 @@ func (h *generateHandler) generateKeysInRegion(ctx context.Context, region strin
 			claims.ReportType = revisedReportType
 			batchTime = batchTime.Add(h.config.KeyRevisionDelay)
 
-			exposures, _, err := h.transformer.TransformPublish(ctx, publish, regions, &claims, batchTime)
+			result, err := h.transformer.TransformPublish(ctx, publish, regions, &claims, batchTime)
 			if err != nil {
 				return fmt.Errorf("failed to transform generated exposures: %w", err)
 			}
 
 			// Build the revision token
 			var token pb.RevisionTokenData
-			for _, e := range exposures {
+			for _, e := range result.Exposures {
 				token.RevisableKeys = append(token.RevisableKeys, &pb.RevisableKey{
 					TemporaryExposureKey: e.ExposureKey,
 					IntervalNumber:       e.IntervalNumber,
@@ -210,7 +210,7 @@ func (h *generateHandler) generateKeysInRegion(ctx context.Context, region strin
 
 			// Bypass revision token enforcement on generated data.
 			n, err := h.database.InsertAndReviseExposures(ctx, &publishdb.InsertAndReviseExposuresRequest{
-				Incoming:     exposures,
+				Incoming:     result.Exposures,
 				Token:        &token,
 				RequireToken: true,
 			})
diff --git a/internal/publish/database/exposure.go b/internal/publish/database/exposure.go
index 005b3d7..0aec779 100644
--- a/internal/publish/database/exposure.go
+++ b/internal/publish/database/exposure.go
@@ -397,6 +397,9 @@ type InsertAndReviseExposuresRequest struct {
 	Incoming []*model.Exposure
 	Token    *pb.RevisionTokenData
 
+	// Optional: if provided stats will be updated transactionally with the TEKs.
+	PublishInfo *model.PublishInfo
+
 	// RequireToken requires that the request supply a revision token to re-upload
 	// existing keys.
 	RequireToken bool
@@ -423,15 +426,15 @@ type InsertAndReviseExposuresRequest struct {
 type InsertAndReviseExposuresResponse struct {
 	// Inserted is the number of new exposures that were inserted into the
 	// database.
-	Inserted uint64
+	Inserted uint32
 
 	// Revised is the number of exposures that matched an existing TEK and were
 	// subsequently revised.
-	Revised uint64
+	Revised uint32
 
 	// Dropped is the number of exposures that were not inserted or updated. This
 	// could be because they weren't present in the revision token, etc.
-	Dropped uint64
+	Dropped uint32
 
 	// Exposures is the actual exposures that were inserted or updated in this
 	// call.
@@ -450,6 +453,9 @@ func (db *PublishDB) InsertAndReviseExposures(ctx context.Context, req *InsertAn
 		return nil, fmt.Errorf("configuration paradox: skipRevisions and onlyRevisions are both set to true")
 	}
 
+	// Save a handle to the publish info stats, which may be nil.
+	stats := req.PublishInfo
+
 	// Maintain a record of the number of exposures inserted and updated, and a
 	// record of the exposures that were actually inserted/updated after merge
 	// logic.
@@ -594,7 +600,7 @@ func (db *PublishDB) InsertAndReviseExposures(ctx context.Context, req *InsertAn
 		}
 
 		// Calculate the number of dropped responses.
-		resp.Dropped = uint64(len(req.Incoming) - len(incomingMap))
+		resp.Dropped = uint32(len(req.Incoming) - len(incomingMap))
 
 		// If we got this far, the revision token is valid for this request, not
 		// required, or bypassed. It's possible that keys were given, but none of
@@ -628,6 +634,13 @@ func (db *PublishDB) InsertAndReviseExposures(ctx context.Context, req *InsertAn
 		if err != nil {
 			return fmt.Errorf("preparing update statement: %v", err)
 		}
+
+		// only possible if all passed in keys are already existing and not revisions.
+		if len(exposures) == 0 {
+			return nil
+		}
+
+		healthAuthorityID := exposures[0].HealthAuthorityID
 		for _, exp := range exposures {
 			if exp.RevisedAt == nil {
 				if exp.ReportType == verifyapi.ReportTypeNegative {
@@ -643,7 +656,23 @@ func (db *PublishDB) InsertAndReviseExposures(ctx context.Context, req *InsertAn
 				}
 				resp.Revised++
 			}
+
+			if (healthAuthorityID == nil && exp.HealthAuthorityID != nil) ||
+				(healthAuthorityID != nil && exp.HealthAuthorityID != nil && *healthAuthorityID != *exp.HealthAuthorityID) {
+				return fmt.Errorf("more than one health authority present in publish")
+			}
+		}
+
+		// If requested, update the publish stats for the associated health authority.
+		if stats != nil && healthAuthorityID != nil {
+			// For all practical purposes - this can be no more than a couple hundred TEKs in a single transaction.
+			stats.NumTEKs = int32(resp.Inserted) + int32(resp.Revised)
+			stats.Revision = resp.Revised > 0
+			if err := db.UpdateStatsInTx(ctx, tx, stats.CreatedAt, *healthAuthorityID, stats); err != nil {
+				return fmt.Errorf("failed to update statistics: %w", err)
+			}
 		}
+
 		return nil
 	}); err != nil {
 		return nil, err
diff --git a/internal/publish/database/stats.go b/internal/publish/database/stats.go
new file mode 100644
index 0000000..dac69c3
--- /dev/null
+++ b/internal/publish/database/stats.go
@@ -0,0 +1,133 @@
+// 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 (
+	"context"
+	"fmt"
+	"time"
+
+	"github.com/google/exposure-notifications-server/internal/publish/model"
+	pgx "github.com/jackc/pgx/v4"
+)
+
+// ReadStats will return all stats before the current hour, ordered in ascending time.
+func (db *PublishDB) ReadStats(ctx context.Context, healthAuthorityID int64) ([]*model.HealthAuthorityStats, error) {
+	if healthAuthorityID <= 0 {
+		return nil, fmt.Errorf("missing healthAuthorityID")
+	}
+
+	// Slightly larger than normal upper bound.
+	results := make([]*model.HealthAuthorityStats, 0, 15*24)
+	// This time is only used to init structures, and immediately overridden from the database read.
+	now := time.Now().UTC()
+
+	err := db.db.InTx(ctx, pgx.ReadCommitted, func(tx pgx.Tx) error {
+		rows, err := tx.Query(ctx, `
+		SELECT
+			health_authority_id, hour, publish, teks, revisions, oldest_tek_days, onset_age_days, missing_onset
+		FROM
+			HealthAuthorityStats
+		WHERE
+			health_authority_id=$1
+		ORDER BY hour ASC
+		`, healthAuthorityID)
+		if err != nil {
+			return fmt.Errorf("read stats: %w", err)
+		}
+
+		for rows.Next() {
+			// The time the hour is initialized to doesn't matter, the scan will override it.
+			stats := model.InitHour(healthAuthorityID, now)
+			if err := scanOneHealthAuthorityStats(rows, stats); err != nil {
+				return err
+			}
+			results = append(results, stats)
+		}
+		return nil
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	return results, nil
+}
+
+func scanOneHealthAuthorityStats(rows pgx.Rows, stats *model.HealthAuthorityStats) error {
+	return rows.Scan(
+		&stats.HealthAuthorityID, &stats.Hour, &stats.PublishCount, &stats.TEKCount,
+		&stats.RevisionCount, &stats.OldestTekDays, &stats.OnsetAgeDays, &stats.MissingOnset)
+}
+
+// UpdateStats performance a read-modify-write to update the requested stats.
+func (db *PublishDB) UpdateStats(ctx context.Context, hour time.Time, healthAuthorityID int64, info *model.PublishInfo) error {
+	return db.db.InTx(ctx, pgx.ReadCommitted, func(tx pgx.Tx) error {
+		return db.UpdateStatsInTx(ctx, tx, hour, healthAuthorityID, info)
+	})
+}
+
+// UpdateStatsInTx records information for the health authority during a publish request, in an existing database transaction.
+func (db *PublishDB) UpdateStatsInTx(ctx context.Context, tx pgx.Tx, hour time.Time, healthAuthorityID int64, info *model.PublishInfo) error {
+	if healthAuthorityID <= 0 {
+		return nil
+	}
+
+	hour = hour.UTC().Truncate(time.Hour)
+
+	rows, err := tx.Query(ctx, `
+		SELECT
+			health_authority_id, hour, publish, teks, revisions, oldest_tek_days, onset_age_days, missing_onset
+		FROM
+			HealthAuthorityStats
+		WHERE
+			health_authority_id=$1 AND hour=$2
+		FOR UPDATE
+		`, healthAuthorityID, hour)
+	if err != nil {
+		return fmt.Errorf("read stats: %w", err)
+	}
+
+	stats := model.InitHour(healthAuthorityID, hour)
+	if rows.Next() {
+		// This hour already exists, so load it.
+		if err := scanOneHealthAuthorityStats(rows, stats); err != nil {
+			rows.Close()
+			return fmt.Errorf("failed to scan stats: %w", err)
+		}
+	}
+	// Thw rows.close here isn't deferred because it needs to be closed before the exec below.
+	rows.Close()
+
+	stats.AddPublish(info)
+
+	// Insert/Update the stats for this hour.
+	_, err = tx.Exec(ctx, `
+		INSERT INTO
+			HealthAuthorityStats
+			(health_authority_id, hour, publish, teks, revisions, oldest_tek_days, onset_age_days, missing_onset)
+		VALUES
+			($1, $2, $3, $4, $5, $6, $7, $8)
+		ON CONFLICT (health_authority_id, hour) DO
+			UPDATE
+			SET publish=$3, teks=$4, revisions=$5, oldest_tek_days=$6, onset_age_days=$7, missing_onset=$8
+		`,
+		stats.HealthAuthorityID, stats.Hour, stats.PublishCount, stats.TEKCount, stats.RevisionCount,
+		stats.OldestTekDays, stats.OnsetAgeDays, stats.MissingOnset)
+	if err != nil {
+		return fmt.Errorf("update stats: %w", err)
+	}
+
+	return nil
+}
diff --git a/internal/publish/model/exposure_model.go b/internal/publish/model/exposure_model.go
index ca9e77e..7696d36 100644
--- a/internal/publish/model/exposure_model.go
+++ b/internal/publish/model/exposure_model.go
@@ -382,23 +382,20 @@ func TimeForIntervalNumber(interval int32) time.Time {
 	return time.Unix(int64(verifyapi.IntervalLength.Seconds())*int64(interval), 0)
 }
 
-// DaysFromSymptomOnset calculates the number of days between two start intervals.
-// Partial days are rounded up/down to the closest day.
-// If the checkInterval is before the onsetInterval, number of days will be negative.
-func DaysFromSymptomOnset(onsetInterval int32, checkInterval int32) int32 {
-	distance := checkInterval - onsetInterval
+// DaysBetweenIntervals calculates the number of days between two start intervals.
+// The intervals represent their start interval (UTC midnight). Partial days always
+// round up.
+func DaysBetweenIntervals(a int32, b int32) int32 {
+	a = IntervalNumber(timeutils.UTCMidnight(TimeForIntervalNumber(a)))
+	b = IntervalNumber(timeutils.UTCMidnight(TimeForIntervalNumber(b)))
+	distance := b - a
 	days := distance / verifyapi.MaxIntervalCount
-	// if the days don't divide evenly, round (up or down) to the closest even day.
+	// if the days don't divide evenly go to 1 magnitude larger.
 	if rem := distance % verifyapi.MaxIntervalCount; rem != 0 {
-		// remainder of negative number is negative in go. So if the ABS is more than
-		// half a day, adjust the day count.
-		if math.Abs(float64(rem)) > verifyapi.MaxIntervalCount/2 {
-			// Account for the fact that if day is 0 and rem is > half a day, sign of rem matters.
-			if days < 0 || rem < 0 {
-				days--
-			} else {
-				days++
-			}
+		if days < 0 {
+			days--
+		} else {
+			days++
 		}
 	}
 	return days
@@ -549,6 +546,12 @@ func ReportTypeTransmissionRisk(reportType string, providedTR int) int {
 	return verifyapi.TransmissionRiskUnknown
 }
 
+type TransformPublishResult struct {
+	Exposures   []*Exposure
+	PublishInfo *PublishInfo
+	Warnings    []string
+}
+
 // TransformPublish converts incoming key data to a list of exposure entities.
 // The data in the request is validated during the transform, including:
 //
@@ -558,7 +561,7 @@ func ReportTypeTransmissionRisk(reportType string, providedTR int) int {
 // The return params are the list of exposures, a list of warnings, and any
 // errors that occur.
 //
-func (t *Transformer) TransformPublish(ctx context.Context, inData *verifyapi.Publish, regions []string, claims *verification.VerifiedClaims, batchTime time.Time) ([]*Exposure, []string, error) {
+func (t *Transformer) TransformPublish(ctx context.Context, inData *verifyapi.Publish, regions []string, claims *verification.VerifiedClaims, batchTime time.Time) (*TransformPublishResult, error) {
 	logger := logging.FromContext(ctx)
 	if t.debugReleaseSameDay {
 		logger.Errorf("DEBUG SERVER - Current day keys are not being embargoed.")
@@ -568,17 +571,24 @@ func (t *Transformer) TransformPublish(ctx context.Context, inData *verifyapi.Pu
 	if len(inData.Keys) == 0 {
 		msg := "no exposure keys in publish request"
 		logger.Debugf(msg)
-		return nil, nil, fmt.Errorf(msg)
+		return nil, fmt.Errorf(msg)
 	}
 	if len(inData.Keys) > t.maxExposureKeys {
 		msg := fmt.Sprintf("too many exposure keys in publish: %v, max of %v is allowed", len(inData.Keys), t.maxExposureKeys)
 		logger.Debugf(msg)
-		return nil, nil, fmt.Errorf(msg)
+		return nil, fmt.Errorf(msg)
 	}
 
 	defaultCreatedAt := TruncateWindow(batchTime, t.truncateWindow)
 	entities := make([]*Exposure, 0, len(inData.Keys))
 
+	// Some of the stats of the publish request can be calculated in line with the transform.
+	// Some won't matter until after the save, so this structure is created
+	// here and returned for further updating.
+	stats := &PublishInfo{
+		CreatedAt: defaultCreatedAt,
+	}
+
 	settings := KeyTransform{
 		// An exposure key must have an interval >= minInterval (max configured age)
 		MinStartInterval: IntervalNumber(batchTime.Add(-1 * t.maxIntervalStartAge)),
@@ -615,9 +625,15 @@ func (t *Transformer) TransformPublish(ctx context.Context, inData *verifyapi.Pu
 	// There are launched applications using this sever that rely on this
 	// behavior - that are passing invalid symotom onset interviews, those
 	// are screened about above when the onsetInterval is set.
-	if daysSince := math.Abs(float64(DaysFromSymptomOnset(onsetInterval, currentInterval))); onsetInterval == 0 || daysSince > float64(t.maxValidSymptomOnsetReportDays) {
+	if daysSince := math.Abs(float64(DaysBetweenIntervals(onsetInterval, currentInterval))); onsetInterval == 0 || daysSince > float64(t.maxValidSymptomOnsetReportDays) {
 		logger.Debugw("defaulting days since symptom onset")
 		onsetInterval = IntervalNumber(timeutils.SubtractDays(batchTime, t.defaultSymptomOnsetDaysAgo))
+		stats.MissingOnset = true
+	}
+
+	// If an onset was provided, that should be put in the stats for this publish.
+	if !stats.MissingOnset {
+		stats.OnsetDaysAgo = int(DaysBetweenIntervals(onsetInterval, currentInterval))
 	}
 
 	// Regions are a multi-value property, uppercase them for storage.
@@ -650,7 +666,7 @@ func (t *Transformer) TransformPublish(ctx context.Context, inData *verifyapi.Pu
 		}
 		// Set days since onset, either from the API or from the verified claims (see above).
 		if onsetInterval > 0 {
-			daysSince := DaysFromSymptomOnset(onsetInterval, exposure.IntervalNumber)
+			daysSince := DaysBetweenIntervals(onsetInterval, exposure.IntervalNumber)
 			// Note that previously this returned an error, but this broke the iOS
 			// implementation since it is unable to handle partial success. As such,
 			// it was converted to a warning that's a separate field in the API
@@ -665,13 +681,20 @@ func (t *Transformer) TransformPublish(ctx context.Context, inData *verifyapi.Pu
 			exposure.SetDaysSinceSymptomOnset(daysSince)
 		}
 
+		// Check and see many days old the key is.
+		if daysOld := DaysBetweenIntervals(exposure.IntervalNumber, currentInterval); daysOld > int32(stats.OldestDays) {
+			stats.OldestDays = int(daysOld)
+		}
+
 		exposure.Traveler = inData.Traveler
 		entities = append(entities, exposure)
 	}
 
 	if len(entities) == 0 {
-		// All keys in the batch are valid.
-		return nil, transformWarnings, transformErrors.ErrorOrNil()
+		// All keys in the batch are invalid.
+		return &TransformPublishResult{
+			Warnings: transformWarnings,
+		}, transformErrors.ErrorOrNil()
 	}
 
 	// Validate the uploaded data meets configuration parameters.
@@ -707,7 +730,9 @@ func (t *Transformer) TransformPublish(ctx context.Context, inData *verifyapi.Pu
 		if ex.IntervalNumber < nextInterval {
 			msg := fmt.Sprintf("exposure keys have non aligned overlapping intervals. %v overlaps with previous key that is good from %v to %v.", ex.IntervalNumber, lastInterval, nextInterval)
 			logger.Debugf(msg)
-			return nil, transformWarnings, fmt.Errorf(msg)
+			return &TransformPublishResult{
+				Warnings: transformWarnings,
+			}, fmt.Errorf(msg)
 		}
 		// OK, current key starts at or after the end of the previous one. Advance both variables.
 		lastInterval = ex.IntervalNumber
@@ -718,9 +743,15 @@ func (t *Transformer) TransformPublish(ctx context.Context, inData *verifyapi.Pu
 		if v > t.maxSameDayKeys {
 			msg := fmt.Sprintf("too many overlapping keys for start interval: %v want: <= %v, got: %v", k, t.maxSameDayKeys, v)
 			logger.Debugf(msg)
-			return nil, transformWarnings, fmt.Errorf(msg)
+			return &TransformPublishResult{
+				Warnings: transformWarnings,
+			}, fmt.Errorf(msg)
 		}
 	}
 
-	return entities, transformWarnings, transformErrors.ErrorOrNil()
+	return &TransformPublishResult{
+		Exposures:   entities,
+		PublishInfo: stats,
+		Warnings:    transformWarnings,
+	}, transformErrors.ErrorOrNil()
 }
diff --git a/internal/publish/model/stats.go b/internal/publish/model/stats.go
new file mode 100644
index 0000000..2d39951
--- /dev/null
+++ b/internal/publish/model/stats.go
@@ -0,0 +1,118 @@
+// 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 model is a model abstraction for health authority telemetry.
+package model
+
+import (
+	"time"
+)
+
+const (
+	// StatsMaxOldestTEK represents the oldest age (days) that will be reflected in stats.
+	// Anything >= will count in the largest bucket.
+	StatsMaxOldestTEK = 15
+	// StatsMaxOnsetDays represents the oldest symptom onset age that will be reflected in stats.
+	// Anything >= will count in the largest bucket.
+	StatsMaxOnsetDays = 29
+
+	PlatformAndroid = "android"
+	PlatformIOS     = "ios"
+	PlatformUnknown = "unknown"
+)
+
+// Turns a platform identifier string into an int for calculation
+func platformToInt(platform string) int {
+	switch platform {
+	case PlatformAndroid:
+		return 1
+	case PlatformIOS:
+		return 2
+	default:
+		return 0
+	}
+}
+
+// HealthAuthorityStats represents the raw metrics for an individual
+// health authority for a given hour.
+type HealthAuthorityStats struct {
+	HealthAuthorityID int64
+	Hour              time.Time
+	PublishCount      []int64
+	TEKCount          int64
+	RevisionCount     int64
+	OldestTekDays     []int64
+	OnsetAgeDays      []int64
+	MissingOnset      int64
+}
+
+// InitHour creates a HealthAuthorityStats record for specified hour.
+func InitHour(healthAuthorityID int64, hour time.Time) *HealthAuthorityStats {
+	return &HealthAuthorityStats{
+		HealthAuthorityID: healthAuthorityID,
+		Hour:              hour.UTC().Truncate(time.Hour),
+		PublishCount:      make([]int64, 3),
+		TEKCount:          0,
+		RevisionCount:     0,
+		OldestTekDays:     make([]int64, StatsMaxOldestTEK+1),
+		OnsetAgeDays:      make([]int64, StatsMaxOnsetDays+1),
+		MissingOnset:      0,
+	}
+}
+
+// PublishInfo is the paremeters to the AddPublish call
+type PublishInfo struct {
+	CreatedAt    time.Time
+	Platform     string
+	NumTEKs      int32
+	Revision     bool
+	OldestDays   int
+	OnsetDaysAgo int
+	MissingOnset bool
+}
+
+// AddPublish increments the stats for a given hour. This should be called
+// inside of a read-modify-write database transaction. The HealthAuthorityStats
+// represents the current state in the database, and the PublishInfo provided is
+// added to it.
+//
+// The HealthAuthorityStats must be created by InitHour or may not be initialized correctly.
+//
+// This method does not enforce that it is called in a transaction, it only
+// applyes the in-memory logic.
+func (has *HealthAuthorityStats) AddPublish(info *PublishInfo) {
+	has.PublishCount[platformToInt(info.Platform)]++
+
+	has.TEKCount += int64(info.NumTEKs)
+	if info.Revision {
+		has.RevisionCount++
+		return
+	}
+	// This info is only updated if it's not a revision.
+	if age, length := info.OldestDays, len(has.OldestTekDays); age >= 0 && age < length {
+		has.OldestTekDays[info.OldestDays]++
+	} else if age >= length {
+		// count this in the last, >= bucket.
+		has.OldestTekDays[length-1]++
+	}
+	if info.MissingOnset {
+		has.MissingOnset++
+	} else {
+		if oAge, length := info.OnsetDaysAgo, len(has.OnsetAgeDays); oAge >= 0 && oAge < length {
+			has.OnsetAgeDays[oAge]++
+		} else if oAge >= length {
+			has.OnsetAgeDays[length-1]++
+		}
+	}
+}
diff --git a/internal/publish/platform.go b/internal/publish/platform.go
new file mode 100644
index 0000000..9005cb2
--- /dev/null
+++ b/internal/publish/platform.go
@@ -0,0 +1,45 @@
+// 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 publish
+
+import (
+	"strings"
+
+	"github.com/google/exposure-notifications-server/internal/publish/model"
+)
+
+func platform(userAgent string) string {
+	switch {
+	case isAndroid(userAgent):
+		return model.PlatformAndroid
+	case isIOS(userAgent):
+		return model.PlatformIOS
+	default:
+		return model.PlatformUnknown
+	}
+}
+
+// isAndroid determines if a User-Agent is a Android device.
+func isAndroid(userAgent string) bool {
+	return strings.Contains(strings.ToLower(userAgent), "android")
+}
+
+// isIOS determines if a User-Agent is an iOS EN device.
+// for EN Express publishes, the useragent is "bluetoothd" and "cfnetwork"
+func isIOS(userAgent string) bool {
+	lowerAgent := strings.ToLower(userAgent)
+	return strings.Contains(lowerAgent, "iphone") ||
+		(strings.Contains(lowerAgent, "bluetoothd") && strings.Contains(lowerAgent, "cfnetwork"))
+}
diff --git a/internal/publish/publish.go b/internal/publish/publish.go
index 7f25d0a..83a780f 100644
--- a/internal/publish/publish.go
+++ b/internal/publish/publish.go
@@ -185,7 +185,7 @@ func newVersionBridge(regions []string) *versionBridge {
 
 // process runs the publish business logic over a "v1" version of the publish request
 // and knows how to join in data from previous versions (the provided versionBridge)
-func (h *PublishHandler) process(ctx context.Context, data *verifyapi.Publish, bridge *versionBridge) *response {
+func (h *PublishHandler) process(ctx context.Context, data *verifyapi.Publish, platform string, bridge *versionBridge) *response {
 	ctx, span := trace.StartSpan(ctx, "(*publish.PublishHandler).process")
 	defer span.End()
 
@@ -329,7 +329,11 @@ func (h *PublishHandler) process(ctx context.Context, data *verifyapi.Publish, b
 	}
 
 	batchTime := time.Now()
-	exposures, transformWarnings, transformError := h.transformer.TransformPublish(ctx, data, regions, verifiedClaims, batchTime)
+	result, transformError := h.transformer.TransformPublish(ctx, data, regions, verifiedClaims, batchTime)
+	// Break apart the result object for easier usage below.
+	exposures := result.Exposures
+	publishInfo := result.PublishInfo
+	transformWarnings := result.Warnings
 	// Check for non-recoverable error. It is possible that individual keys are dropped, but if there
 	// are any valid ones, we will try and move forward.
 	// If at the end, there is a success, the transformError will be returned as supplemental information.
@@ -350,9 +354,15 @@ func (h *PublishHandler) process(ctx context.Context, data *verifyapi.Publish, b
 		}
 	}
 
+	// Add in the platform
+	if publishInfo != nil {
+		publishInfo.Platform = platform
+	}
+
 	resp, err := h.database.InsertAndReviseExposures(ctx, &database.InsertAndReviseExposuresRequest{
-		Incoming: exposures,
-		Token:    token,
+		Incoming:    exposures,
+		Token:       token,
+		PublishInfo: publishInfo,
 
 		RequireToken:          !appConfig.BypassRevisionToken,
 		AllowPartialRevisions: h.config.AllowPartialRevisions,
diff --git a/internal/publish/publish_v1.go b/internal/publish/publish_v1.go
index 33f66af..630b910 100644
--- a/internal/publish/publish_v1.go
+++ b/internal/publish/publish_v1.go
@@ -58,7 +58,8 @@ func (h *PublishHandler) handleRequest(w http.ResponseWriter, r *http.Request) *
 		}
 	}
 
-	return h.process(ctx, &data, newVersionBridge([]string{}))
+	clientPlatform := platform(r.UserAgent())
+	return h.process(ctx, &data, clientPlatform, newVersionBridge([]string{}))
 }
 
 // Handle returns an http.Handler that can process V1 publish requests.
diff --git a/internal/publish/publish_v1alpha1.go b/internal/publish/publish_v1alpha1.go
index 00e8691..5cd8ba9 100644
--- a/internal/publish/publish_v1alpha1.go
+++ b/internal/publish/publish_v1alpha1.go
@@ -76,7 +76,8 @@ func (h *PublishHandler) handleV1Apha1Request(w http.ResponseWriter, r *http.Req
 	}
 	bridge := newVersionBridge(data.Regions)
 
-	return h.process(ctx, &publish, bridge)
+	clientPlatform := platform(r.UserAgent())
+	return h.process(ctx, &publish, clientPlatform, bridge)
 }
 
 func (h *PublishHandler) HandleV1Alpha1() http.Handler {
diff --git a/migrations/000054_HealthAuthorityStats.down.sql b/migrations/000054_HealthAuthorityStats.down.sql
new file mode 100644
index 0000000..5275012
--- /dev/null
+++ b/migrations/000054_HealthAuthorityStats.down.sql
@@ -0,0 +1,19 @@
+-- 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.
+
+BEGIN;
+
+DROP TABLE HealthAuthorityStats;
+
+END;
diff --git a/migrations/000054_HealthAuthorityStats.up.sql b/migrations/000054_HealthAuthorityStats.up.sql
new file mode 100644
index 0000000..698cbe2
--- /dev/null
+++ b/migrations/000054_HealthAuthorityStats.up.sql
@@ -0,0 +1,38 @@
+-- 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.
+
+BEGIN;
+
+CREATE TABLE HealthAuthorityStats(
+    health_authority_id INT NOT NULL REFERENCES HealthAuthority(id) ON DELETE CASCADE,
+    -- UTC hour
+    hour TIMESTAMPTZ NOT NULL,
+    -- 3 elements, android/iphone/unknown
+    publish BIGINT [],
+    -- number of TEKs provided
+    teks BIGINT NOT NULL DEFAULT 0,
+    -- number of TEKs that were revised
+    revisions BIGINT NOT NULL DEFAULT 0,
+    -- Age of the oldest TEKs from an individual publish request. Index is number of days. 0-14
+    oldest_tek_days BIGINT [],    
+    -- Symptom onset to upload ranges, index is number of days. 0-28.
+    onset_age_days BIGINT [],
+    -- Indicator of where the symptom onset was backfilled.
+    missing_onset BIGINT DEFAULT 0
+);
+
+CREATE UNIQUE INDEX
+    idx_health_authority_stats_hour ON HealthAuthorityStats (health_authority_id, hour);
+
+END;


Test patch:
diff --git a/internal/federationin/federationin_test.go b/internal/federationin/federationin_test.go
index 1a60195..0d35b33 100644
--- a/internal/federationin/federationin_test.go
+++ b/internal/federationin/federationin_test.go
@@ -106,8 +106,8 @@ func (idb *publishDB) insertExposures(ctx context.Context, req *publishdb.Insert
 	idb.exposures = append(idb.exposures, req.Incoming...)
 	return &publishdb.InsertAndReviseExposuresResponse{
 		Exposures: req.Incoming,
-		Inserted:  uint64(len(req.Incoming)),
-		Revised:   uint64(len(req.Incoming)), // mock doesn't know if it is revising or inserting.
+		Inserted:  uint32(len(req.Incoming)),
+		Revised:   uint32(len(req.Incoming)), // mock doesn't know if it is revising or inserting.
 	}, nil
 }
 
diff --git a/internal/publish/database/stats_test.go b/internal/publish/database/stats_test.go
new file mode 100644
index 0000000..0716e8c
--- /dev/null
+++ b/internal/publish/database/stats_test.go
@@ -0,0 +1,108 @@
+// 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 (
+	"context"
+	"testing"
+	"time"
+
+	"github.com/google/exposure-notifications-server/internal/publish/model"
+	hadb "github.com/google/exposure-notifications-server/internal/verification/database"
+	hamodel "github.com/google/exposure-notifications-server/internal/verification/model"
+)
+
+func TestUpdateStats(t *testing.T) {
+	t.Parallel()
+
+	ctx := context.Background()
+	testDB, _ := testDatabaseInstance.NewDatabase(t)
+	testPublishDB := New(testDB)
+
+	testHADB := hadb.New(testDB)
+	healthAuthority := hamodel.HealthAuthority{
+		Issuer:   "a",
+		Audience: "b",
+		Name:     "c",
+	}
+	if err := testHADB.AddHealthAuthority(ctx, &healthAuthority); err != nil {
+		t.Fatalf("unable to cerate health authority: %v", err)
+	}
+
+	hour := time.Now().UTC().Truncate(time.Hour)
+
+	info := &model.PublishInfo{
+		Platform:     model.PlatformAndroid,
+		NumTEKs:      14,
+		Revision:     false,
+		OldestDays:   14,
+		OnsetDaysAgo: 4,
+		MissingOnset: false,
+	}
+
+	if err := testPublishDB.UpdateStats(ctx, hour, healthAuthority.ID, info); err != nil {
+		t.Fatalf("updating stats: %v", err)
+	}
+
+	// Update again - test conflict codepath.
+	if err := testPublishDB.UpdateStats(ctx, hour, healthAuthority.ID, info); err != nil {
+		t.Fatalf("updating stats: %v", err)
+	}
+}
+
+func TestReadStats(t *testing.T) {
+	t.Parallel()
+
+	ctx := context.Background()
+	testDB, _ := testDatabaseInstance.NewDatabase(t)
+	testPublishDB := New(testDB)
+
+	testHADB := hadb.New(testDB)
+	healthAuthority := hamodel.HealthAuthority{
+		Issuer:   "a",
+		Audience: "b",
+		Name:     "c",
+	}
+	if err := testHADB.AddHealthAuthority(ctx, &healthAuthority); err != nil {
+		t.Fatalf("unable to cerate health authority: %v", err)
+	}
+
+	info := &model.PublishInfo{
+		Platform:     model.PlatformAndroid,
+		NumTEKs:      14,
+		Revision:     false,
+		OldestDays:   14,
+		OnsetDaysAgo: 4,
+		MissingOnset: false,
+	}
+
+	startTime := time.Now().UTC().Add(-10 * time.Hour).Truncate(time.Hour)
+	endTime := startTime.Add(10 * time.Hour)
+
+	for hour := startTime; !hour.After(endTime); hour = hour.Add(time.Hour) {
+		if err := testPublishDB.UpdateStats(ctx, hour, healthAuthority.ID, info); err != nil {
+			t.Fatalf("updating stats: %v", err)
+		}
+	}
+
+	stats, err := testPublishDB.ReadStats(ctx, healthAuthority.ID)
+	if err != nil {
+		t.Fatalf("unexpected error reading stats: %v", err)
+	}
+
+	if len(stats) != 11 {
+		t.Fatalf("added 11 hours of stats, got: %v", len(stats))
+	}
+}
diff --git a/internal/publish/model/exposure_model_test.go b/internal/publish/model/exposure_model_test.go
index 03d32be..f01c0b5 100644
--- a/internal/publish/model/exposure_model_test.go
+++ b/internal/publish/model/exposure_model_test.go
@@ -149,7 +149,7 @@ func TestInvalidBase64(t *testing.T) {
 	regions := []string{"US"}
 	batchTime := time.Date(2020, 3, 1, 10, 43, 1, 0, time.UTC)
 
-	_, _, err = transformer.TransformPublish(ctx, source, regions, nil, batchTime)
+	_, err = transformer.TransformPublish(ctx, source, regions, nil, batchTime)
 	expErr := `key 0 cannot be imported: illegal base64 data at input byte 4`
 	if err == nil || !strings.Contains(err.Error(), expErr) {
 		t.Errorf("expected error '%v', got: %v", expErr, err)
@@ -346,7 +346,7 @@ func TestPublishValidation(t *testing.T) {
 				t.Fatalf("unepected error: %v", err)
 			}
 
-			_, _, err = tf.TransformPublish(ctx, c.p, []string{}, nil, captureStartTime)
+			_, err = tf.TransformPublish(ctx, c.p, []string{}, nil, captureStartTime)
 			if err == nil {
 				if c.m != "" {
 					t.Errorf("want error '%v', got nil", c.m)
@@ -375,7 +375,7 @@ func encodeKey(key []byte) string {
 }
 
 func TestStillValidKey(t *testing.T) {
-	now := time.Now()
+	now := timeutils.UTCMidnight(time.Now())
 	batchWindow := TruncateWindow(now, time.Minute)
 	intervalNumber := IntervalNumber(now) - 1
 
@@ -433,11 +433,13 @@ func TestStillValidKey(t *testing.T) {
 			}
 
 			ctx := context.Background()
-			tf, _, err := transformer.TransformPublish(ctx, &tc.source, []string{}, nil, now)
+			result, err := transformer.TransformPublish(ctx, &tc.source, []string{}, nil, now)
 			if err != nil {
 				t.Fatal(err)
 			}
 
+			tf := result.Exposures
+
 			if len(tf) != 1 {
 				t.Fatalf("wrong number of keys, want: 1 got :%v", len(tf))
 			}
@@ -501,6 +503,7 @@ func TestTransform(t *testing.T) {
 		Regions      []string
 		Claims       *verification.VerifiedClaims
 		Want         []*Exposure
+		WantStats    *PublishInfo
 		PartialError string
 		Warnings     []string
 	}{
@@ -583,6 +586,11 @@ func TestTransform(t *testing.T) {
 					DaysSinceSymptomOnset: int32Ptr(0),
 				},
 			},
+			WantStats: &PublishInfo{
+				CreatedAt:    batchTimeRounded,
+				OldestDays:   7,
+				MissingOnset: true,
+			},
 		},
 		{
 			Name: "report_type_transmission_risks",
@@ -633,6 +641,11 @@ func TestTransform(t *testing.T) {
 					DaysSinceSymptomOnset: int32Ptr(-2),
 				},
 			},
+			WantStats: &PublishInfo{
+				CreatedAt:    batchTimeRounded,
+				OldestDays:   7,
+				MissingOnset: true,
+			},
 		},
 		{
 			Name: "claims_with_report_type_no_backfill",
@@ -702,6 +715,11 @@ func TestTransform(t *testing.T) {
 					DaysSinceSymptomOnset: int32Ptr(1),
 				},
 			},
+			WantStats: &PublishInfo{
+				CreatedAt:    batchTimeRounded,
+				OldestDays:   7,
+				OnsetDaysAgo: 6,
+			},
 		},
 		{
 			Name: "claims_with_report_type_with_backfill",
@@ -772,6 +790,11 @@ func TestTransform(t *testing.T) {
 					HealthAuthorityID:     int64Ptr(27),
 				},
 			},
+			WantStats: &PublishInfo{
+				CreatedAt:    batchTimeRounded,
+				OldestDays:   7,
+				OnsetDaysAgo: 5,
+			},
 		},
 		{
 			Name: "user_provided_symptom_onset",
@@ -843,6 +866,11 @@ func TestTransform(t *testing.T) {
 					HealthAuthorityID:     int64Ptr(27),
 				},
 			},
+			WantStats: &PublishInfo{
+				CreatedAt:    batchTimeRounded,
+				OldestDays:   7,
+				OnsetDaysAgo: 6,
+			},
 		},
 		{
 			Name: "user_provided_symptom_unreasonable",
@@ -913,6 +941,11 @@ func TestTransform(t *testing.T) {
 					HealthAuthorityID:     int64Ptr(27),
 				},
 			},
+			WantStats: &PublishInfo{
+				CreatedAt:    batchTimeRounded,
+				OldestDays:   5,
+				MissingOnset: true,
+			},
 		},
 		{
 			Name: "symptom_onset_too_large",
@@ -952,6 +985,11 @@ func TestTransform(t *testing.T) {
 					HealthAuthorityID:     int64Ptr(27),
 				},
 			},
+			WantStats: &PublishInfo{
+				CreatedAt:    batchTimeRounded,
+				OldestDays:   7,
+				OnsetDaysAgo: 21,
+			},
 			Warnings: []string{"key 1 symptom onset is too large, 15 > 14 - saving without this key"},
 		},
 	}
@@ -973,7 +1011,7 @@ func TestTransform(t *testing.T) {
 
 	for _, tc := range cases {
 		t.Run(tc.Name, func(t *testing.T) {
-			got, warnings, err := transformer.TransformPublish(ctx, tc.Publish, tc.Regions, tc.Claims, batchTime)
+			result, err := transformer.TransformPublish(ctx, tc.Publish, tc.Regions, tc.Claims, batchTime)
 			if err != nil && tc.PartialError == "" {
 				t.Fatalf("TransformPublish returned unexpected error: %v", err)
 			} else if tc.PartialError != "" {
@@ -986,16 +1024,19 @@ func TestTransform(t *testing.T) {
 
 			if exp := tc.Warnings; len(exp) > 0 {
 				sort.Strings(exp)
-				sort.Strings(warnings)
+				sort.Strings(result.Warnings)
 
-				if !reflect.DeepEqual(exp, warnings) {
-					t.Errorf("expected %#v to be %#v", warnings, exp)
+				if !reflect.DeepEqual(exp, result.Warnings) {
+					t.Errorf("expected %#v to be %#v", result.Warnings, exp)
 				}
 			}
 
-			if diff := cmp.Diff(tc.Want, got, cmpopts.IgnoreUnexported(Exposure{})); diff != "" {
+			if diff := cmp.Diff(tc.Want, result.Exposures, cmpopts.IgnoreUnexported(Exposure{})); diff != "" {
 				t.Errorf("TransformPublish mismatch (-want +got):\n%v", diff)
 			}
+			if diff := cmp.Diff(tc.WantStats, result.PublishInfo, cmpopts.IgnoreUnexported(Exposure{})); diff != "" {
+				t.Errorf("stats mismatch (-want +got):\n%v", diff)
+			}
 		})
 	}
 }
@@ -1070,16 +1111,16 @@ func TestDefaultSymptomOnset(t *testing.T) {
 			}
 
 			ctx := context.Background()
-			tf, _, err := transformer.TransformPublish(ctx, &tc.source, []string{}, nil, now)
+			result, err := transformer.TransformPublish(ctx, &tc.source, []string{}, nil, now)
 			if err != nil {
 				t.Fatal(err)
 			}
 
-			if len(tf) != 1 {
-				t.Fatalf("wrong number of keys, want: 1 got :%v", len(tf))
+			if len(result.Exposures) != 1 {
+				t.Fatalf("wrong number of keys, want: 1 got :%v", len(result.Exposures))
 			}
 
-			if diff := cmp.Diff(tf[0].DaysSinceSymptomOnset, tc.wantDaysSinceOnset); diff != "" {
+			if diff := cmp.Diff(result.Exposures[0].DaysSinceSymptomOnset, tc.wantDaysSinceOnset); diff != "" {
 				t.Errorf("mismatch (-want, +got):\n%s", diff)
 			}
 		})
@@ -1237,7 +1278,7 @@ func TestTransformOverlapping(t *testing.T) {
 			if err != nil {
 				t.Fatalf("NewTransformer returned unexpected error: %v", err)
 			}
-			_, _, err = transformer.TransformPublish(ctx, &tc.source, tc.regions, nil, now)
+			_, err = transformer.TransformPublish(ctx, &tc.source, tc.regions, nil, now)
 			if err != nil && tc.error == "" {
 				t.Fatalf("unexpected error, want: nil, got: %v", err)
 			} else if err != nil && !strings.Contains(err.Error(), tc.error) {
@@ -1250,7 +1291,8 @@ func TestTransformOverlapping(t *testing.T) {
 }
 
 func TestDaysFromSymptomOnset(t *testing.T) {
-	now := time.Now().UTC()
+	// Node that everything is based on midnight UTC so we'll start there.
+	now := timeutils.UTCMidnight(time.Now().UTC())
 
 	cases := []struct {
 		name  string
@@ -1277,10 +1319,10 @@ func TestDaysFromSymptomOnset(t *testing.T) {
 			want:  1,
 		},
 		{
-			name:  "next_day_round_up",
+			name:  "next_day_round_down_2",
 			onset: IntervalNumber(now),
 			check: IntervalNumber(now.Add(37 * time.Hour)),
-			want:  2,
+			want:  1,
 		},
 		{
 			name:  "previous_day",
@@ -1292,7 +1334,7 @@ func TestDaysFromSymptomOnset(t *testing.T) {
 			name:  "previous_day_round_down",
 			onset: IntervalNumber(now),
 			check: IntervalNumber(now.Add(-25 * time.Hour)),
-			want:  -1,
+			want:  -2,
 		},
 		{
 			name:  "previous_day_round_up",
@@ -1310,7 +1352,7 @@ func TestDaysFromSymptomOnset(t *testing.T) {
 
 	for _, tc := range cases {
 		t.Run(tc.name, func(t *testing.T) {
-			got := DaysFromSymptomOnset(tc.onset, tc.check)
+			got := DaysBetweenIntervals(tc.onset, tc.check)
 			if tc.want != got {
 				t.Fatalf("wrong day instance between %v and %v, got: %v want: %v", tc.onset, tc.check, got, tc.want)
 			}
diff --git a/internal/publish/model/stats_test.go b/internal/publish/model/stats_test.go
new file mode 100644
index 0000000..9b72578
--- /dev/null
+++ b/internal/publish/model/stats_test.go
@@ -0,0 +1,139 @@
+// 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 model
+
+import (
+	"testing"
+	"time"
+
+	"github.com/google/go-cmp/cmp"
+)
+
+func TestCheckAddPublish(t *testing.T) {
+	t.Parallel()
+
+	now := time.Now().UTC().Truncate(time.Hour)
+	record := InitHour(1, now)
+
+	want := InitHour(1, now)
+	compare(want, record, t)
+
+	{
+		info := PublishInfo{
+			Platform:     PlatformAndroid,
+			NumTEKs:      14,
+			Revision:     false,
+			OldestDays:   14,
+			OnsetDaysAgo: 4,
+			MissingOnset: false,
+		}
+
+		record.AddPublish(&info)
+
+		want = &HealthAuthorityStats{
+			HealthAuthorityID: want.HealthAuthorityID,
+			Hour:              want.Hour,
+			PublishCount:      []int64{0, 1, 0},
+			TEKCount:          14,
+			RevisionCount:     0,
+			OldestTekDays:     []int64{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0},
+			OnsetAgeDays:      []int64{0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
+			MissingOnset:      0,
+		}
+		compare(want, record, t)
+	}
+
+	{
+		info := PublishInfo{
+			Platform:     PlatformIOS,
+			NumTEKs:      10,
+			Revision:     true,
+			OldestDays:   10,
+			OnsetDaysAgo: 3,
+			MissingOnset: false,
+		}
+
+		record.AddPublish(&info)
+
+		want = &HealthAuthorityStats{
+			HealthAuthorityID: want.HealthAuthorityID,
+			Hour:              want.Hour,
+			PublishCount:      []int64{0, 1, 1},
+			TEKCount:          24,
+			RevisionCount:     1,
+			OldestTekDays:     []int64{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0},
+			OnsetAgeDays:      []int64{0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
+			MissingOnset:      0,
+		}
+		compare(want, record, t)
+	}
+
+	{
+		info := PublishInfo{
+			Platform:     "palm",
+			NumTEKs:      5,
+			Revision:     false,
+			OldestDays:   5,
+			OnsetDaysAgo: 4,
+			MissingOnset: true,
+		}
+
+		record.AddPublish(&info)
+
+		want = &HealthAuthorityStats{
+			HealthAuthorityID: want.HealthAuthorityID,
+			Hour:              want.Hour,
+			PublishCount:      []int64{1, 1, 1},
+			TEKCount:          29,
+			RevisionCount:     1,
+			OldestTekDays:     []int64{0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0},
+			OnsetAgeDays:      []int64{0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
+			MissingOnset:      1,
+		}
+		compare(want, record, t)
+	}
+
+	{
+		info := PublishInfo{
+			Platform:     PlatformAndroid,
+			NumTEKs:      20,
+			Revision:     false,
+			OldestDays:   StatsMaxOldestTEK + 5,
+			OnsetDaysAgo: StatsMaxOnsetDays + 5,
+			MissingOnset: false,
+		}
+
+		record.AddPublish(&info)
+
+		want = &HealthAuthorityStats{
+			HealthAuthorityID: want.HealthAuthorityID,
+			Hour:              want.Hour,
+			PublishCount:      []int64{1, 2, 1},
+			TEKCount:          49,
+			RevisionCount:     1,
+			OldestTekDays:     []int64{0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1},
+			OnsetAgeDays:      []int64{0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1},
+			MissingOnset:      1,
+		}
+		compare(want, record, t)
+	}
+}
+
+func compare(want, got *HealthAuthorityStats, t *testing.T) {
+	t.Helper()
+	if diff := cmp.Diff(want, got); diff != "" {
+		t.Fatalf("mismatch (-want, +got):\n%s", diff)
+	}
+}
diff --git a/internal/publish/platform_test.go b/internal/publish/platform_test.go
new file mode 100644
index 0000000..32dff8c
--- /dev/null
+++ b/internal/publish/platform_test.go
@@ -0,0 +1,66 @@
+// 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 publish
+
+import (
+	"testing"
+
+	"github.com/google/exposure-notifications-server/internal/publish/model"
+)
+
+func TestPlatform(t *testing.T) {
+	t.Parallel()
+
+	cases := []struct {
+		name         string
+		userAgent    string
+		wantPlatform string
+	}{
+		{
+			name:         "iOS_with_iphone",
+			userAgent:    "some test IPHONE some other text",
+			wantPlatform: model.PlatformIOS,
+		},
+		{
+			name:         "ios_bluetoothd",
+			userAgent:    "bluetoothd (unknown version) CFNetwork/1206 Darwin/20.1.0",
+			wantPlatform: model.PlatformIOS,
+		},
+		{
+			name:         "android_nokia",
+			userAgent:    "Dalvik/2.1.0 (Linux; U; Android 9; Nokia 3.1 C Build/PPR1.180610.011)",
+			wantPlatform: model.PlatformAndroid,
+		},
+		{
+			name:         "android_pixel",
+			userAgent:    "Dalvik/2.1.0 (Linux; U; Android 10; Pixel 4 Build/QQ3A.200805.001)",
+			wantPlatform: model.PlatformAndroid,
+		},
+		{
+			name:         "unknown",
+			userAgent:    "Google Chrome",
+			wantPlatform: model.PlatformUnknown,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			if got := platform(tc.userAgent); got != tc.wantPlatform {
+				t.Fatalf("wrong platform, want: %q got: %q", tc.wantPlatform, got)
+			}
+		})
+	}
+
+}
diff --git a/internal/publish/publish_test.go b/internal/publish/publish_test.go
index 9ce0d09..5ea1b92 100644
--- a/internal/publish/publish_test.go
+++ b/internal/publish/publish_test.go
@@ -114,10 +114,12 @@ func TestPublishWithBypass(t *testing.T) {
 		HealthAuthorityKey *vermodel.HealthAuthorityKey // Automatically linked to SigningKey
 		AuthorizedApp      *aamodel.AuthorizedApp       // Automatically linked to health authorities.
 		Publish            verifyapi.Publish
+		UserAgent          string
 		Regions            []string
 		JWTTiming          time.Duration
 		ReportType         string
 		WantTRAdjustment   []int
+		WantStats          []*model.HealthAuthorityStats
 		Code               int
 		Error              string
 		ErrorCode          string
@@ -125,6 +127,7 @@ func TestPublishWithBypass(t *testing.T) {
 		SkipKeys           map[int]bool // which keys should be skipped (partial success)
 		MaintenanceMode    bool
 	}{
+
 		{
 			Name:       "successful_insert_bypass_ha_verification",
 			TestRegion: regions.next(),
@@ -268,10 +271,20 @@ func TestPublishWithBypass(t *testing.T) {
 				return authApp
 			}(),
 			Publish: verifyapi.Publish{
-				Keys:                util.GenerateExposureKeys(2, 5, false),
-				Traveler:            true,
-				HealthAuthorityID:   names.current(),
-				VerificationPayload: "totally not a JWT",
+				Keys:                 util.GenerateExposureKeys(2, 5, false),
+				Traveler:             true,
+				HealthAuthorityID:    names.current(),
+				VerificationPayload:  "totally not a JWT",
+				SymptomOnsetInterval: model.IntervalNumber(time.Now().Add(-24 * time.Hour)),
+			},
+			UserAgent: "an android phone",
+			WantStats: []*model.HealthAuthorityStats{
+				{
+					PublishCount:  []int64{0, 1, 0},
+					TEKCount:      2,
+					OldestTekDays: []int64{0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
+					OnsetAgeDays:  []int64{0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
+				},
 			},
 			ReportType: verifyapi.ReportTypeConfirmed,
 			Regions:    []string{}, // will receive defaults
@@ -296,9 +309,19 @@ func TestPublishWithBypass(t *testing.T) {
 				return authApp
 			}(),
 			Publish: verifyapi.Publish{
-				Keys:                util.GenerateExposureKeys(2, 0, false),
-				HealthAuthorityID:   names.current(),
-				VerificationPayload: "totally not a JWT",
+				Keys:                 util.GenerateExposureKeys(2, 0, false),
+				HealthAuthorityID:    names.current(),
+				VerificationPayload:  "totally not a JWT",
+				SymptomOnsetInterval: model.IntervalNumber(time.Now().UTC().Add(-96 * time.Hour)),
+			},
+			UserAgent: "bluetoothd (unknown version) CFNetwork/1197 Darwin/20.0.0",
+			WantStats: []*model.HealthAuthorityStats{
+				{
+					PublishCount:  []int64{0, 0, 1},
+					TEKCount:      2,
+					OldestTekDays: []int64{0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
+					OnsetAgeDays:  []int64{0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
+				},
 			},
 			Regions:          []string{regions.current()},
 			ReportType:       verifyapi.ReportTypeConfirmed,
@@ -328,6 +351,16 @@ func TestPublishWithBypass(t *testing.T) {
 				HealthAuthorityID:   names.current(),
 				VerificationPayload: "totally not a JWT",
 			},
+			UserAgent: "some unknown user agent",
+			WantStats: []*model.HealthAuthorityStats{
+				{
+					PublishCount:  []int64{1, 0, 0},
+					TEKCount:      2,
+					OldestTekDays: []int64{0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
+					OnsetAgeDays:  []int64{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0},
+					MissingOnset:  1,
+				},
+			},
 			Regions:          []string{regions.current()},
 			ReportType:       verifyapi.ReportTypeClinical,
 			WantTRAdjustment: []int{verifyapi.TransmissionRiskClinical, verifyapi.TransmissionRiskClinical},
@@ -555,7 +588,13 @@ func TestPublishWithBypass(t *testing.T) {
 				if tc.ContentType != "" {
 					contentType = tc.ContentType
 				}
-				resp, err := server.Client().Post(server.URL, contentType, strings.NewReader(string(jsonString)))
+				request, err := http.NewRequest("POST", server.URL, strings.NewReader(string(jsonString)))
+				if err != nil {
+					t.Fatal(err)
+				}
+				request.Header.Set("Content-Type", contentType)
+				request.Header.Set("User-Agent", tc.UserAgent)
+				resp, err := server.Client().Do(request)
 				if err != nil {
 					t.Fatal(err)
 				}
@@ -700,6 +739,26 @@ func TestPublishWithBypass(t *testing.T) {
 						if diff := cmp.Diff(tokenWant.RevisableKeys, revToken.RevisableKeys, revisableSorter, cmpopts.IgnoreUnexported(pb.RevisableKey{})); diff != "" {
 							t.Errorf("mismatch (-want, +got):\n%s", diff)
 						}
+
+						if tc.HealthAuthority != nil {
+							// There was a valid certificate present. there should be statistics.
+							stats, err := pubDB.ReadStats(ctx, tc.HealthAuthority.ID)
+							if err != nil {
+								t.Fatalf("error reading back stats after publish: %v", err)
+							}
+
+							// We don't know the health authority ID in advance.
+							for _, ws := range tc.WantStats {
+								ws.HealthAuthorityID = tc.HealthAuthority.ID
+							}
+
+							ignoreFields := cmpopts.IgnoreFields(model.HealthAuthorityStats{}, "Hour")
+
+							if diff := cmp.Diff(tc.WantStats, stats, ignoreFields); diff != "" {
+								t.Errorf("mismatch (-want, +got):\n%s", diff)
+							}
+						}
+
 					} else {
 						if !strings.Contains(response.ErrorMessage, tc.Error) {
 							t.Errorf("missing error text '%v', got '%+v'", tc.Error, response)