diff --git a/apps/gateway/controllers/version.go b/apps/gateway/controllers/version.go index bb6395b..d6b7b91 100644 --- a/apps/gateway/controllers/version.go +++ b/apps/gateway/controllers/version.go @@ -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 { @@ -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 diff --git a/apps/gateway/controllers/version_test.go b/apps/gateway/controllers/version_test.go index f9a8108..fce10d4 100644 --- a/apps/gateway/controllers/version_test.go +++ b/apps/gateway/controllers/version_test.go @@ -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") diff --git a/apps/gateway/cors_test.go b/apps/gateway/cors_test.go index fcaa83f..c980b79 100644 --- a/apps/gateway/cors_test.go +++ b/apps/gateway/cors_test.go @@ -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"), ) }) diff --git a/apps/gateway/metrics/session.go b/apps/gateway/metrics/session.go index 0d5801e..7166a4a 100644 --- a/apps/gateway/metrics/session.go +++ b/apps/gateway/metrics/session.go @@ -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" ) @@ -20,6 +20,7 @@ type AppPlatform string const ( AppPlatformIOS AppPlatform = "ios" AppPlatformAndroid AppPlatform = "android" + AppPlatformWeb AppPlatform = "web" ) type SessionMetrics struct { diff --git a/apps/gateway/services/session.go b/apps/gateway/services/session.go index 57b4afb..57cb8f2 100644 --- a/apps/gateway/services/session.go +++ b/apps/gateway/services/session.go @@ -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, } @@ -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]) } diff --git a/apps/gateway/utils/app_client.go b/apps/gateway/utils/app_client.go new file mode 100644 index 0000000..72f8c16 --- /dev/null +++ b/apps/gateway/utils/app_client.go @@ -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" +)