Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/gateway/controllers/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (

"github.com/MaroonRides/api/apps/gateway/config"
"github.com/MaroonRides/api/apps/gateway/services"
"github.com/MaroonRides/api/apps/gateway/utils"
)

type VersionController struct {
Expand All @@ -28,7 +29,8 @@ type SupportedVersionResponse struct {

// The app checks its version once per launch, so each call is one session.
func (c *VersionController) supportedVersion(fc fuego.ContextNoBody) (SupportedVersionResponse, error) {
c.sessions.Started(fc.Request().UserAgent())
r := fc.Request()
c.sessions.Started(r.Header.Get(utils.AppPlatformHeader), r.Header.Get(utils.AppVersionHeader), r.UserAgent())
return SupportedVersionResponse{
Version: config.MinimumSupportedVersion,
}, nil
Expand Down
35 changes: 34 additions & 1 deletion apps/gateway/controllers/version_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,47 @@ var _ = Describe("VersionController", Label("unit"), func() {
handler = server.Mux
})

checkVersion := func(userAgent string) {
checkVersionWithHeaders := func(platform, version, userAgent string) {
req := httptest.NewRequest(http.MethodGet, "/api/version/supported", nil)
req.Header.Set("X-App-Platform", platform)
req.Header.Set("X-App-Version", version)
req.Header.Set("User-Agent", userAgent)
res := httptest.NewRecorder()
handler.ServeHTTP(res, req)
ExpectWithOffset(1, res.Code).To(Equal(http.StatusOK))
}

checkVersion := func(userAgent string) {
checkVersionWithHeaders("", "", userAgent)
}

It("counts a session from the platform and version headers, ahead of the user agent", func() {
checkVersionWithHeaders("web", "3.0.2", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)")
checkVersionWithHeaders("android", "3.1.0", "Maroon Rides App (iOS) v3.0.0")

Expect(testutil.GatherAndCompare(registry, strings.NewReader(`
# HELP gateway_app_sessions_total App launches, counted by the version check each launch makes, by platform and app version.
# TYPE gateway_app_sessions_total counter
gateway_app_sessions_total{platform="android",version="3.1.0"} 1
gateway_app_sessions_total{platform="web",version="3.0.2"} 1
`), appSessionsMetric)).To(Succeed())
})

DescribeTable("falls back to the user agent when a header is missing or malformed",
func(platform, version string) {
checkVersionWithHeaders(platform, version, "Maroon Rides App (Android) v3.0.1")

Expect(testutil.GatherAndCompare(registry, strings.NewReader(`
# HELP gateway_app_sessions_total App launches, counted by the version check each launch makes, by platform and app version.
# TYPE gateway_app_sessions_total counter
gateway_app_sessions_total{platform="android",version="3.0.1"} 1
`), appSessionsMetric)).To(Succeed())
},
Entry("no version", "web", ""),
Entry("unknown platform", "electron", "3.0.2"),
Entry("version with a v prefix", "web", "v3.0.2"),
)

It("counts a session by platform and app version", func() {
checkVersion("Maroon Rides App (iOS) v3.0.0")
checkVersion("Maroon Rides App (iOS) v3.0.0")
Expand Down
4 changes: 3 additions & 1 deletion apps/gateway/cors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,17 @@ var _ = Describe("CORS", Label("unit"), func() {
req := httptest.NewRequest(http.MethodOptions, "/api/sync/stream", nil)
req.Header.Set("Origin", origin)
req.Header.Set("Access-Control-Request-Method", http.MethodPost)
req.Header.Set("Access-Control-Request-Headers", "content-type")
req.Header.Set("Access-Control-Request-Headers", "content-type,x-app-platform,x-app-version")

res := httptest.NewRecorder()
handler.ServeHTTP(res, req)

Expect(res.Header().Get("Access-Control-Allow-Origin")).To(Equal("*"))
Expect(res.Header().Get("Access-Control-Allow-Headers")).To(Equal("content-type,x-app-platform,x-app-version"))
},
Entry("iOS app", "capacitor://localhost"),
Entry("Android app", "https://localhost"),
Entry("web app", "https://web.maroonrides.app"),
Entry("Vite dev server", "http://100.89.139.58:5173"),
)
})
3 changes: 2 additions & 1 deletion apps/gateway/metrics/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const (
labelPlatform = "platform"
labelVersion = "version"

// Versions past this many share one label so a forged user agent cannot grow the series count.
// Versions past this many share one label so a forged client cannot grow the series count.
maxAppVersions = 50
OtherAppVersion = "other"
)
Expand All @@ -20,6 +20,7 @@ type AppPlatform string
const (
AppPlatformIOS AppPlatform = "ios"
AppPlatformAndroid AppPlatform = "android"
AppPlatformWeb AppPlatform = "web"
)

type SessionMetrics struct {
Expand Down
22 changes: 18 additions & 4 deletions apps/gateway/services/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,19 @@ import (
"github.com/MaroonRides/api/apps/gateway/metrics"
)

// The app sets its user agent in capacitor.config.ts, e.g. "Maroon Rides App (iOS) v3.0.0".
var appVersion = regexp.MustCompile(`^\d+\.\d+\.\d+$`)

// Values of the X-App-Platform header, as Capacitor's getPlatform() returns them.
var headerPlatforms = map[string]metrics.AppPlatform{
"ios": metrics.AppPlatformIOS,
"android": metrics.AppPlatformAndroid,
"web": metrics.AppPlatformWeb,
}

// Builds from before the app sent its own headers only name themselves here, set in capacitor.config.ts, e.g. "Maroon Rides App (iOS) v3.0.0".
var appUserAgent = regexp.MustCompile(`Maroon Rides App \((iOS|Android)\) v(\d+\.\d+\.\d+)`)

var appPlatforms = map[string]metrics.AppPlatform{
var userAgentPlatforms = map[string]metrics.AppPlatform{
"iOS": metrics.AppPlatformIOS,
"Android": metrics.AppPlatformAndroid,
}
Expand All @@ -23,10 +32,15 @@ func NewSessionService(m *metrics.SessionMetrics) *SessionService {
}

// Started ignores anything that is not the app, since the kubelet probes the same endpoint.
func (s *SessionService) Started(userAgent string) {
func (s *SessionService) Started(platform, version, userAgent string) {
if p, ok := headerPlatforms[platform]; ok && appVersion.MatchString(version) {
s.metrics.Started(p, version)
return
}

match := appUserAgent.FindStringSubmatch(userAgent)
if match == nil {
return
}
s.metrics.Started(appPlatforms[match[1]], match[2])
s.metrics.Started(userAgentPlatforms[match[1]], match[2])
}
7 changes: 7 additions & 0 deletions apps/gateway/utils/app_client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package utils

// A browser cannot set its user agent, so the app names itself in these headers on every platform.
const (
AppPlatformHeader = "X-App-Platform"
AppVersionHeader = "X-App-Version"
)
Loading