feat: add metrics handling and patch event activity logging

This commit is contained in:
Tony
2026-06-21 05:09:36 +08:00
parent 09a22900cd
commit 3a62e35c5d
6 changed files with 705 additions and 0 deletions
+26
View File
@@ -2,6 +2,7 @@ package handlers
import (
"net/http"
"time"
"github.com/google/uuid"
"github.com/shorebird-server/internal/api/middleware"
@@ -39,6 +40,7 @@ func (h *DeviceHandler) PatchCheck(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusNotFound, "App not found", nil)
return
}
h.recordPatchCheckActivity(r, appID, req)
// Find the release by app and version
release, err := h.DB.GetReleaseByAppIDAndVersion(r.Context(), appID, req.ReleaseVersion)
@@ -137,6 +139,30 @@ func (h *DeviceHandler) PatchCheck(w http.ResponseWriter, r *http.Request) {
})
}
func (h *DeviceHandler) recordPatchCheckActivity(r *http.Request, appID uuid.UUID, req models.PatchCheckRequest) {
if req.ClientID == nil || *req.ClientID == "" {
return
}
patchNumber := 0
if req.CurrentPatchNumber != nil {
patchNumber = *req.CurrentPatchNumber
} else if req.PatchNumber != nil {
patchNumber = *req.PatchNumber
}
_ = h.DB.InsertPatchEvent(
r.Context(),
appID,
*req.ClientID,
req.Arch,
req.Platform,
req.ReleaseVersion,
"PatchCheck",
patchNumber,
time.Now().UTC().UnixMilli(),
nil,
)
}
// PatchEvents handles POST /api/v1/patches/events
// Records patch install success/failure events from devices.
// No authentication required.
+620
View File
@@ -0,0 +1,620 @@
package handlers
import (
"net/http"
"sort"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/shorebird-server/internal/db"
)
const (
activeHoursLookbackDays = 30
activeHoursMinDays = 7
activeHoursWindowLength = 2
metricsDefaultWindowDays = 30
versionActiveWindowDays = 30
millisecondsTimestampMin = int64(1000000000000)
)
// MetricsHandler handles app metrics endpoints.
type MetricsHandler struct {
DB db.Store
}
type activeHourEntry struct {
HourUTC int `json:"hour_utc"`
AverageActiveDevices float64 `json:"average_active_devices"`
}
type activeHoursResponse struct {
Hourly []activeHourEntry `json:"hourly"`
RecommendedWindowStartUTC *int `json:"recommended_window_start_utc"`
RecommendedWindowLengthHours int `json:"recommended_window_length_hours"`
BusiestHourUTC *int `json:"busiest_hour_utc"`
LookbackDays int `json:"lookback_days"`
AsOf time.Time `json:"as_of"`
}
type metricsRange struct {
Start time.Time `json:"start"`
End time.Time `json:"end"`
}
type uniqueUsersTimeSeriesEntry struct {
Period time.Time `json:"period"`
UniqueUsers int `json:"unique_users"`
}
type uniqueUsersBreakdownEntry struct {
GroupBy string `json:"group_by"`
GroupValue string `json:"group_value"`
UniqueUsers int `json:"unique_users"`
TimeSeries []uniqueUsersTimeSeriesEntry `json:"time_series,omitempty"`
}
type uniqueUsersWindow struct {
UniqueUsers int `json:"unique_users"`
Range metricsRange `json:"range"`
TimeSeries []uniqueUsersTimeSeriesEntry `json:"time_series,omitempty"`
}
type uniqueUsersCurrentWindow struct {
UniqueUsers int `json:"unique_users"`
Range metricsRange `json:"range"`
TimeSeries []uniqueUsersTimeSeriesEntry `json:"time_series,omitempty"`
Breakdown []uniqueUsersBreakdownEntry `json:"breakdown,omitempty"`
}
type uniqueUsersResponse struct {
AsOf time.Time `json:"as_of"`
Granularity *string `json:"granularity"`
Current uniqueUsersCurrentWindow `json:"current"`
Previous *uniqueUsersWindow `json:"previous"`
}
type versionDistributionEntry struct {
ReleaseVersion *string `json:"release_version"`
DeviceCount int `json:"device_count"`
Percentage float64 `json:"percentage"`
}
type versionDistributionResponse struct {
Entries []versionDistributionEntry `json:"entries"`
TotalDevices int `json:"total_devices"`
ActiveWindowDays int `json:"active_window_days"`
AsOf time.Time `json:"as_of"`
}
type patchAdoptionPoint struct {
Period *time.Time `json:"period"`
Devices int `json:"devices"`
Target int `json:"target"`
AdoptionPct float64 `json:"adoption_pct"`
}
type patchAdoptionEntry struct {
PatchNumber int `json:"patch_number"`
TargetPlatforms []string `json:"target_platforms"`
IsRolledBack bool `json:"is_rolled_back"`
Series []patchAdoptionPoint `json:"series"`
}
type patchAdoptionResponse struct {
ReleaseVersion string `json:"release_version"`
IsLatest bool `json:"is_latest"`
Granularity *string `json:"granularity"`
Range metricsRange `json:"range"`
AsOf time.Time `json:"as_of"`
Patches []patchAdoptionEntry `json:"patches"`
}
// GetActiveHours handles GET /api/v1/apps/{appId}/metrics/active-hours.
func (h *MetricsHandler) GetActiveHours(w http.ResponseWriter, r *http.Request) {
appID, err := uuid.Parse(chi.URLParam(r, "appId"))
if err != nil {
respondError(w, http.StatusBadRequest, "Invalid app ID", nil)
return
}
if _, ok := requireAppAccess(w, r, h.DB, appID, false); !ok {
return
}
now := time.Now().UTC()
since := now.AddDate(0, 0, -activeHoursLookbackDays)
events, err := h.DB.ListPatchEventActivity(
r.Context(),
appID,
since.Unix(),
since.UnixMilli(),
)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to load active hours", nil)
return
}
type hourDayKey struct {
hour int
day string
}
hourDayClients := make(map[hourDayKey]map[string]struct{})
daysSeen := make(map[string]struct{})
for _, event := range events {
t := eventTime(event.Timestamp)
if t.Before(since) || t.After(now) {
continue
}
day := t.Format("2006-01-02")
daysSeen[day] = struct{}{}
key := hourDayKey{hour: t.Hour(), day: day}
if _, ok := hourDayClients[key]; !ok {
hourDayClients[key] = make(map[string]struct{})
}
hourDayClients[key][event.ClientID] = struct{}{}
}
averages := make([]float64, 24)
for hour := 0; hour < 24; hour++ {
var total int
for day := 0; day < activeHoursLookbackDays; day++ {
key := hourDayKey{
hour: hour,
day: now.AddDate(0, 0, -day).Format("2006-01-02"),
}
total += len(hourDayClients[key])
}
averages[hour] = float64(total) / float64(activeHoursLookbackDays)
}
hourly := make([]activeHourEntry, 24)
for hour := 0; hour < 24; hour++ {
hourly[hour] = activeHourEntry{
HourUTC: hour,
AverageActiveDevices: averages[hour],
}
}
var recommendedStart *int
var busiestHour *int
if len(daysSeen) >= activeHoursMinDays {
start := lowestActivityWindowStart(averages, activeHoursWindowLength)
busiest := busiestHourUTC(averages)
recommendedStart = &start
busiestHour = &busiest
}
respondJSON(w, http.StatusOK, activeHoursResponse{
Hourly: hourly,
RecommendedWindowStartUTC: recommendedStart,
RecommendedWindowLengthHours: activeHoursWindowLength,
BusiestHourUTC: busiestHour,
LookbackDays: activeHoursLookbackDays,
AsOf: now,
})
}
// GetUniqueUsers handles GET /api/v1/apps/{appId}/metrics/unique-users.
func (h *MetricsHandler) GetUniqueUsers(w http.ResponseWriter, r *http.Request) {
appID, ok := h.authorizeMetricsRequest(w, r)
if !ok {
return
}
now := time.Now().UTC()
currentRange := metricRangeFromQuery(r, now)
window := currentRange.End.Sub(currentRange.Start)
previousRange := metricsRange{
Start: currentRange.Start.Add(-window),
End: currentRange.Start,
}
granularity := validGranularity(r.URL.Query().Get("granularity"))
groupBy := r.URL.Query().Get("group_by")
events, err := h.DB.ListPatchEventActivity(r.Context(), appID, previousRange.Start.Unix(), previousRange.Start.UnixMilli())
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to load unique users", nil)
return
}
current := uniqueUsersCurrentWindow{
UniqueUsers: countUniqueDevices(eventsInRange(events, currentRange)),
Range: currentRange,
}
previous := &uniqueUsersWindow{
UniqueUsers: countUniqueDevices(eventsInRange(events, previousRange)),
Range: previousRange,
}
if granularity != nil {
current.TimeSeries = uniqueUsersSeries(events, currentRange, *granularity)
previous.TimeSeries = uniqueUsersSeries(events, previousRange, *granularity)
}
if groupBy == "platform" {
current.Breakdown = uniqueUsersPlatformBreakdown(eventsInRange(events, currentRange), currentRange, granularity)
}
respondJSON(w, http.StatusOK, uniqueUsersResponse{
AsOf: now,
Granularity: granularity,
Current: current,
Previous: previous,
})
}
// GetVersionDistribution handles GET /api/v1/apps/{appId}/metrics/version-distribution.
func (h *MetricsHandler) GetVersionDistribution(w http.ResponseWriter, r *http.Request) {
appID, ok := h.authorizeMetricsRequest(w, r)
if !ok {
return
}
now := time.Now().UTC()
since := now.AddDate(0, 0, -versionActiveWindowDays)
events, err := h.DB.ListPatchEventActivity(r.Context(), appID, since.Unix(), since.UnixMilli())
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to load version distribution", nil)
return
}
latestByDevice := map[string]db.PatchEventActivityRow{}
for _, event := range events {
t := eventTime(event.Timestamp)
if t.Before(since) || t.After(now) {
continue
}
current, ok := latestByDevice[event.ClientID]
if !ok || eventTime(current.Timestamp).Before(t) {
latestByDevice[event.ClientID] = event
}
}
counts := map[string]int{}
for _, event := range latestByDevice {
counts[event.ReleaseVersion]++
}
total := len(latestByDevice)
entries := make([]versionDistributionEntry, 0, len(counts))
for version, count := range counts {
var pct float64
if total > 0 {
pct = float64(count) / float64(total)
}
versionCopy := version
var versionPtr *string
if versionCopy != "" {
versionPtr = &versionCopy
}
entries = append(entries, versionDistributionEntry{
ReleaseVersion: versionPtr,
DeviceCount: count,
Percentage: pct,
})
}
sort.Slice(entries, func(i, j int) bool {
if entries[i].DeviceCount == entries[j].DeviceCount {
if entries[i].ReleaseVersion == nil {
return false
}
if entries[j].ReleaseVersion == nil {
return true
}
return *entries[i].ReleaseVersion < *entries[j].ReleaseVersion
}
return entries[i].DeviceCount > entries[j].DeviceCount
})
respondJSON(w, http.StatusOK, versionDistributionResponse{
Entries: entries,
TotalDevices: total,
ActiveWindowDays: versionActiveWindowDays,
AsOf: now,
})
}
// GetPatchAdoption handles GET /api/v1/apps/{appId}/metrics/patch-adoption.
func (h *MetricsHandler) GetPatchAdoption(w http.ResponseWriter, r *http.Request) {
appID, ok := h.authorizeMetricsRequest(w, r)
if !ok {
return
}
now := time.Now().UTC()
metricsRange := metricRangeFromQuery(r, now)
granularity := validGranularity(r.URL.Query().Get("granularity"))
releaseVersion := r.URL.Query().Get("release_version")
isLatest := releaseVersion == ""
if releaseVersion == "" {
releases, err := h.DB.GetReleasesByAppID(r.Context(), appID, false)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to load releases", nil)
return
}
if len(releases) > 0 {
releaseVersion = releases[0].Version
}
}
events, err := h.DB.ListPatchEventActivity(r.Context(), appID, metricsRange.Start.Unix(), metricsRange.Start.UnixMilli())
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to load patch adoption", nil)
return
}
events = eventsInRange(events, metricsRange)
if releaseVersion != "" {
filtered := events[:0]
for _, event := range events {
if event.ReleaseVersion == releaseVersion {
filtered = append(filtered, event)
}
}
events = filtered
}
patchPlatforms := map[int]map[string]struct{}{}
for _, event := range events {
if event.PatchNumber <= 0 {
continue
}
if _, ok := patchPlatforms[event.PatchNumber]; !ok {
patchPlatforms[event.PatchNumber] = map[string]struct{}{}
}
if event.Platform != "" {
patchPlatforms[event.PatchNumber][event.Platform] = struct{}{}
}
}
patchNumbers := make([]int, 0, len(patchPlatforms))
for patchNumber := range patchPlatforms {
patchNumbers = append(patchNumbers, patchNumber)
}
sort.Ints(patchNumbers)
entries := make([]patchAdoptionEntry, 0, len(patchNumbers))
for _, patchNumber := range patchNumbers {
platforms := sortedKeys(patchPlatforms[patchNumber])
if len(platforms) == 0 {
platforms = []string{"android", "ios", "linux", "macos", "windows"}
}
entries = append(entries, patchAdoptionEntry{
PatchNumber: patchNumber,
TargetPlatforms: platforms,
IsRolledBack: false,
Series: patchAdoptionSeries(events, metricsRange, granularity, patchNumber, patchPlatforms[patchNumber]),
})
}
respondJSON(w, http.StatusOK, patchAdoptionResponse{
ReleaseVersion: releaseVersion,
IsLatest: isLatest,
Granularity: granularity,
Range: metricsRange,
AsOf: now,
Patches: entries,
})
}
func (h *MetricsHandler) authorizeMetricsRequest(w http.ResponseWriter, r *http.Request) (uuid.UUID, bool) {
appID, err := uuid.Parse(chi.URLParam(r, "appId"))
if err != nil {
respondError(w, http.StatusBadRequest, "Invalid app ID", nil)
return uuid.Nil, false
}
if _, ok := requireAppAccess(w, r, h.DB, appID, false); !ok {
return uuid.Nil, false
}
return appID, true
}
func eventTime(timestamp int64) time.Time {
if timestamp >= millisecondsTimestampMin {
return time.UnixMilli(timestamp).UTC()
}
return time.Unix(timestamp, 0).UTC()
}
func metricRangeFromQuery(r *http.Request, now time.Time) metricsRange {
end := parseMetricTime(r.URL.Query().Get("end"), now)
start := parseMetricTime(r.URL.Query().Get("start"), end.AddDate(0, 0, -metricsDefaultWindowDays))
if !start.Before(end) {
start = end.AddDate(0, 0, -metricsDefaultWindowDays)
}
return metricsRange{Start: start.UTC(), End: end.UTC()}
}
func parseMetricTime(value string, fallback time.Time) time.Time {
if value == "" {
return fallback.UTC()
}
if t, err := time.Parse(time.RFC3339Nano, value); err == nil {
return t.UTC()
}
if t, err := time.Parse("2006-01-02", value); err == nil {
return t.UTC()
}
return fallback.UTC()
}
func validGranularity(value string) *string {
switch value {
case "hour", "day", "week":
return &value
default:
return nil
}
}
func eventsInRange(events []db.PatchEventActivityRow, metricRange metricsRange) []db.PatchEventActivityRow {
result := make([]db.PatchEventActivityRow, 0, len(events))
for _, event := range events {
t := eventTime(event.Timestamp)
if !t.Before(metricRange.Start) && t.Before(metricRange.End) {
result = append(result, event)
}
}
return result
}
func countUniqueDevices(events []db.PatchEventActivityRow) int {
devices := map[string]struct{}{}
for _, event := range events {
devices[event.ClientID] = struct{}{}
}
return len(devices)
}
func uniqueUsersSeries(events []db.PatchEventActivityRow, metricRange metricsRange, granularity string) []uniqueUsersTimeSeriesEntry {
buckets := map[time.Time]map[string]struct{}{}
for _, event := range eventsInRange(events, metricRange) {
period := bucketStart(eventTime(event.Timestamp), granularity)
if _, ok := buckets[period]; !ok {
buckets[period] = map[string]struct{}{}
}
buckets[period][event.ClientID] = struct{}{}
}
periods := make([]time.Time, 0, len(buckets))
for period := range buckets {
periods = append(periods, period)
}
sort.Slice(periods, func(i, j int) bool { return periods[i].Before(periods[j]) })
series := make([]uniqueUsersTimeSeriesEntry, 0, len(periods))
for _, period := range periods {
series = append(series, uniqueUsersTimeSeriesEntry{Period: period, UniqueUsers: len(buckets[period])})
}
return series
}
func uniqueUsersPlatformBreakdown(events []db.PatchEventActivityRow, metricRange metricsRange, granularity *string) []uniqueUsersBreakdownEntry {
byPlatform := map[string][]db.PatchEventActivityRow{}
for _, event := range events {
platform := event.Platform
if platform == "" {
platform = "unknown"
}
byPlatform[platform] = append(byPlatform[platform], event)
}
platforms := make([]string, 0, len(byPlatform))
for platform := range byPlatform {
platforms = append(platforms, platform)
}
sort.Strings(platforms)
breakdown := make([]uniqueUsersBreakdownEntry, 0, len(platforms))
for _, platform := range platforms {
entry := uniqueUsersBreakdownEntry{
GroupBy: "platform",
GroupValue: platform,
UniqueUsers: countUniqueDevices(byPlatform[platform]),
}
if granularity != nil {
entry.TimeSeries = uniqueUsersSeries(byPlatform[platform], metricRange, *granularity)
}
breakdown = append(breakdown, entry)
}
return breakdown
}
func patchAdoptionSeries(events []db.PatchEventActivityRow, metricRange metricsRange, granularity *string, patchNumber int, platforms map[string]struct{}) []patchAdoptionPoint {
if granularity == nil {
devices, target := patchAdoptionCounts(events, patchNumber, platforms)
return []patchAdoptionPoint{{Devices: devices, Target: target, AdoptionPct: adoptionPct(devices, target)}}
}
buckets := map[time.Time][]db.PatchEventActivityRow{}
for _, event := range events {
period := bucketStart(eventTime(event.Timestamp), *granularity)
buckets[period] = append(buckets[period], event)
}
periods := make([]time.Time, 0, len(buckets))
for period := range buckets {
periods = append(periods, period)
}
sort.Slice(periods, func(i, j int) bool { return periods[i].Before(periods[j]) })
series := make([]patchAdoptionPoint, 0, len(periods))
for _, period := range periods {
devices, target := patchAdoptionCounts(eventsInRange(buckets[period], metricRange), patchNumber, platforms)
periodCopy := period
series = append(series, patchAdoptionPoint{
Period: &periodCopy,
Devices: devices,
Target: target,
AdoptionPct: adoptionPct(devices, target),
})
}
return series
}
func patchAdoptionCounts(events []db.PatchEventActivityRow, patchNumber int, platforms map[string]struct{}) (int, int) {
targetDevices := map[string]struct{}{}
adoptedDevices := map[string]struct{}{}
for _, event := range events {
if len(platforms) > 0 {
if _, ok := platforms[event.Platform]; !ok {
continue
}
}
targetDevices[event.ClientID] = struct{}{}
if event.PatchNumber >= patchNumber {
adoptedDevices[event.ClientID] = struct{}{}
}
}
return len(adoptedDevices), len(targetDevices)
}
func adoptionPct(devices, target int) float64 {
if target == 0 {
return 0
}
return float64(devices) / float64(target)
}
func bucketStart(t time.Time, granularity string) time.Time {
t = t.UTC()
switch granularity {
case "hour":
return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), 0, 0, 0, time.UTC)
case "week":
weekday := int(t.Weekday())
if weekday == 0 {
weekday = 7
}
day := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
return day.AddDate(0, 0, -(weekday - 1))
default:
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
}
}
func sortedKeys(values map[string]struct{}) []string {
keys := make([]string, 0, len(values))
for value := range values {
keys = append(keys, value)
}
sort.Strings(keys)
return keys
}
func lowestActivityWindowStart(averages []float64, length int) int {
bestStart := 0
bestTotal := windowTotal(averages, 0, length)
for start := 1; start < len(averages); start++ {
total := windowTotal(averages, start, length)
if total < bestTotal {
bestStart = start
bestTotal = total
}
}
return bestStart
}
func busiestHourUTC(averages []float64) int {
busiest := 0
for hour := 1; hour < len(averages); hour++ {
if averages[hour] > averages[busiest] {
busiest = hour
}
}
return busiest
}
func windowTotal(averages []float64, start, length int) float64 {
var total float64
for offset := 0; offset < length; offset++ {
total += averages[(start+offset)%len(averages)]
}
return total
}
+5
View File
@@ -39,6 +39,7 @@ func NewRouter(authService *authpkg.Service, database db.Store, store storage.St
orgHandler := &OrganizationHandler{DB: database}
releaseHandler := &ReleaseHandler{DB: database, Storage: store, BaseURL: baseURL}
patchHandler := &PatchHandler{DB: database, Storage: store}
metricsHandler := &MetricsHandler{DB: database}
deviceHandler := &DeviceHandler{DB: database, Storage: store}
diagHandler := &DiagnosticsHandler{}
adminHandler := &AdminHandler{DB: database}
@@ -96,6 +97,10 @@ func NewRouter(authService *authpkg.Service, database db.Store, store storage.St
r.Post("/apps", appHandler.CreateApp)
r.Delete("/apps/{appId}", appHandler.DeleteApp)
r.Patch("/apps/{appId}/transfer", appHandler.TransferApp)
r.Get("/apps/{appId}/metrics/active-hours", metricsHandler.GetActiveHours)
r.Get("/apps/{appId}/metrics/unique-users", metricsHandler.GetUniqueUsers)
r.Get("/apps/{appId}/metrics/version-distribution", metricsHandler.GetVersionDistribution)
r.Get("/apps/{appId}/metrics/patch-adoption", metricsHandler.GetPatchAdoption)
// Channels
r.Get("/apps/{appId}/channels", channelHandler.GetChannels)
+28
View File
@@ -643,6 +643,34 @@ func (db *PgStore) InsertPatchEvent(ctx context.Context, appID uuid.UUID, client
return err
}
// ListPatchEventActivity returns recent device activity timestamps for an app.
func (db *PgStore) ListPatchEventActivity(ctx context.Context, appID uuid.UUID, sinceSeconds, sinceMillis int64) ([]PatchEventActivityRow, error) {
rows, err := db.query(ctx,
`SELECT client_id, timestamp, platform, release_version, patch_number
FROM patch_events
WHERE app_id = $1
AND client_id <> ''
AND (
(timestamp < 1000000000000 AND timestamp >= $2)
OR (timestamp >= 1000000000000 AND timestamp >= $3)
)`,
appID, sinceSeconds, sinceMillis)
if err != nil {
return nil, err
}
defer rows.Close()
var result []PatchEventActivityRow
for rows.Next() {
var r PatchEventActivityRow
if err := rows.Scan(&r.ClientID, &r.Timestamp, &r.Platform, &r.ReleaseVersion, &r.PatchNumber); err != nil {
return nil, err
}
result = append(result, r)
}
return result, rows.Err()
}
// --- Rolled back patches ---
// RollbackPatch marks a patch as rolled back.
+16
View File
@@ -529,6 +529,22 @@ func (s *SqliteStore) InsertPatchEvent(ctx context.Context, appID uuid.UUID, cli
_, err := s.db.ExecContext(ctx, `INSERT INTO patch_events (app_id, client_id, arch, patch_number, platform, release_version, event_type, timestamp, message) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, appID.String(), clientID, arch, patchNumber, platform, releaseVersion, eventType, timestamp, message)
return err
}
func (s *SqliteStore) ListPatchEventActivity(ctx context.Context, appID uuid.UUID, sinceSeconds, sinceMillis int64) ([]PatchEventActivityRow, error) {
rows, err := s.db.QueryContext(ctx, `SELECT client_id, timestamp, platform, release_version, patch_number FROM patch_events WHERE app_id = ? AND client_id <> '' AND ((timestamp < 1000000000000 AND timestamp >= ?) OR (timestamp >= 1000000000000 AND timestamp >= ?))`, appID.String(), sinceSeconds, sinceMillis)
if err != nil {
return nil, err
}
defer rows.Close()
var result []PatchEventActivityRow
for rows.Next() {
var r PatchEventActivityRow
if err := rows.Scan(&r.ClientID, &r.Timestamp, &r.Platform, &r.ReleaseVersion, &r.PatchNumber); err != nil {
return nil, err
}
result = append(result, r)
}
return result, rows.Err()
}
// --- Rollbacks ---
func (s *SqliteStore) RollbackPatch(ctx context.Context, releaseID, patchNumber int) error {
+10
View File
@@ -139,6 +139,7 @@ type Store interface {
// --- Patch Events ---
InsertPatchEvent(ctx context.Context, appID uuid.UUID, clientID, arch, platform, releaseVersion, eventType string, patchNumber int, timestamp int64, message *string) error
ListPatchEventActivity(ctx context.Context, appID uuid.UUID, sinceSeconds, sinceMillis int64) ([]PatchEventActivityRow, error)
// --- Rollbacks ---
RollbackPatch(ctx context.Context, releaseID, patchNumber int) error
@@ -297,3 +298,12 @@ type AccountTokenRow struct {
UsedAt NullShorebirdTime
CreatedAt ShorebirdTime
}
// PatchEventActivityRow is the minimal activity data needed for metrics.
type PatchEventActivityRow struct {
ClientID string
Timestamp int64
Platform string
ReleaseVersion string
PatchNumber int
}