From 3a62e35c5d1acdf4cc0af9e90d1f8ab17b04e3ba Mon Sep 17 00:00:00 2001 From: Tony Date: Sun, 21 Jun 2026 05:09:36 +0800 Subject: [PATCH] feat: add metrics handling and patch event activity logging --- internal/api/handlers/device.go | 26 ++ internal/api/handlers/metrics.go | 620 +++++++++++++++++++++++++++++++ internal/api/handlers/router.go | 5 + internal/db/db.go | 28 ++ internal/db/sqlite.go | 16 + internal/db/store.go | 10 + 6 files changed, 705 insertions(+) create mode 100644 internal/api/handlers/metrics.go diff --git a/internal/api/handlers/device.go b/internal/api/handlers/device.go index 17ff98e..4258624 100644 --- a/internal/api/handlers/device.go +++ b/internal/api/handlers/device.go @@ -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. diff --git a/internal/api/handlers/metrics.go b/internal/api/handlers/metrics.go new file mode 100644 index 0000000..9bbf147 --- /dev/null +++ b/internal/api/handlers/metrics.go @@ -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 +} diff --git a/internal/api/handlers/router.go b/internal/api/handlers/router.go index bf80f0a..267fe36 100644 --- a/internal/api/handlers/router.go +++ b/internal/api/handlers/router.go @@ -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) diff --git a/internal/db/db.go b/internal/db/db.go index 1ec3f05..88a91bd 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -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. diff --git a/internal/db/sqlite.go b/internal/db/sqlite.go index 52c1146..66ad973 100644 --- a/internal/db/sqlite.go +++ b/internal/db/sqlite.go @@ -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 { diff --git a/internal/db/store.go b/internal/db/store.go index ee2c27e..ea72ffd 100644 --- a/internal/db/store.go +++ b/internal/db/store.go @@ -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 +}