From 9be9ecb8106a5b20fc3b0228a23973a2c6189d14 Mon Sep 17 00:00:00 2001 From: bwees Date: Sat, 14 Nov 2026 13:33:00 -0600 Subject: [PATCH 1/3] feat: identify app sessions by X-App-Platform and X-App-Version headers --- apps/gateway/controllers/app_client.go | 21 +++++ apps/gateway/controllers/report.go | 2 +- apps/gateway/controllers/version.go | 2 +- apps/gateway/controllers/version_test.go | 106 +++++++++++++++++++---- apps/gateway/cors_test.go | 29 +++++-- apps/gateway/dtos/app_client.go | 8 ++ apps/gateway/metrics/session.go | 3 +- apps/gateway/services/report.go | 21 +++-- apps/gateway/services/session.go | 35 ++++++-- 9 files changed, 185 insertions(+), 42 deletions(-) create mode 100644 apps/gateway/controllers/app_client.go create mode 100644 apps/gateway/dtos/app_client.go diff --git a/apps/gateway/controllers/app_client.go b/apps/gateway/controllers/app_client.go new file mode 100644 index 0000000..70ec782 --- /dev/null +++ b/apps/gateway/controllers/app_client.go @@ -0,0 +1,21 @@ +package controllers + +import ( + "net/http" + + "github.com/MaroonRides/api/apps/gateway/dtos" +) + +// 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" +) + +func appClient(r *http.Request) dtos.AppClient { + return dtos.AppClient{ + Platform: r.Header.Get(appPlatformHeader), + Version: r.Header.Get(appVersionHeader), + UserAgent: r.UserAgent(), + } +} diff --git a/apps/gateway/controllers/report.go b/apps/gateway/controllers/report.go index d35e515..126cc1f 100644 --- a/apps/gateway/controllers/report.go +++ b/apps/gateway/controllers/report.go @@ -44,7 +44,7 @@ func (c *ReportController) report(fc fuego.ContextWithBody[dtos.ReportRequest]) return nil, err } - err = c.svc.Record(req, fc.Request().UserAgent()) + err = c.svc.Record(req, appClient(fc.Request())) if errors.Is(err, services.ErrInvalidReport) { return nil, fuego.BadRequestError{Detail: err.Error(), Err: err} } diff --git a/apps/gateway/controllers/version.go b/apps/gateway/controllers/version.go index bb6395b..383d37e 100644 --- a/apps/gateway/controllers/version.go +++ b/apps/gateway/controllers/version.go @@ -28,7 +28,7 @@ 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()) + c.sessions.Started(appClient(fc.Request())) return SupportedVersionResponse{ Version: config.MinimumSupportedVersion, }, nil diff --git a/apps/gateway/controllers/version_test.go b/apps/gateway/controllers/version_test.go index f9a8108..a1421b5 100644 --- a/apps/gateway/controllers/version_test.go +++ b/apps/gateway/controllers/version_test.go @@ -17,7 +17,20 @@ import ( "github.com/MaroonRides/api/apps/gateway/services" ) -const appSessionsMetric = "gateway_app_sessions_total" +const ( + appSessionsMetric = "gateway_app_sessions_total" + appSessionsHeader = ` +# 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 +` + browserUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)" +) + +type appRequest struct { + platform string + version string + userAgent string +} var _ = Describe("VersionController", Label("unit"), func() { var ( @@ -32,41 +45,96 @@ var _ = Describe("VersionController", Label("unit"), func() { handler = server.Mux }) - checkVersion := func(userAgent string) { + checkVersion := func(app appRequest) { req := httptest.NewRequest(http.MethodGet, "/api/version/supported", nil) - req.Header.Set("User-Agent", userAgent) + req.Header.Set("User-Agent", app.userAgent) + if app.platform != "" { + req.Header.Set("X-App-Platform", app.platform) + } + if app.version != "" { + req.Header.Set("X-App-Version", app.version) + } res := httptest.NewRecorder() handler.ServeHTTP(res, req) ExpectWithOffset(1, res.Code).To(Equal(http.StatusOK)) } - 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") - checkVersion("Maroon Rides App (Android) v3.0.1") + expectSessions := func(series string) { + ExpectWithOffset(1, testutil.GatherAndCompare(registry, strings.NewReader(appSessionsHeader+series), appSessionsMetric)).To(Succeed()) + } - 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 + It("counts a session by the platform and version headers", func() { + checkVersion(appRequest{platform: "ios", version: "3.0.2"}) + checkVersion(appRequest{platform: "ios", version: "3.0.2"}) + checkVersion(appRequest{platform: "android", version: "3.0.2"}) + checkVersion(appRequest{platform: "web", version: "3.0.2", userAgent: browserUserAgent}) + + expectSessions(` +gateway_app_sessions_total{platform="android",version="3.0.2"} 1 +gateway_app_sessions_total{platform="ios",version="3.0.2"} 2 +gateway_app_sessions_total{platform="web",version="3.0.2"} 1 +`) + }) + + It("prefers the headers over the user agent", func() { + checkVersion(appRequest{platform: "android", version: "3.1.0", userAgent: "Maroon Rides App (iOS) v3.0.0"}) + + expectSessions(` +gateway_app_sessions_total{platform="android",version="3.1.0"} 1 +`) + }) + + It("counts a build that only names itself in the user agent", func() { + checkVersion(appRequest{userAgent: "Maroon Rides App (iOS) v3.0.0"}) + checkVersion(appRequest{userAgent: "Maroon Rides App (iOS) v3.0.0"}) + checkVersion(appRequest{userAgent: "Maroon Rides App (Android) v3.0.1"}) + + expectSessions(` gateway_app_sessions_total{platform="android",version="3.0.1"} 1 gateway_app_sessions_total{platform="ios",version="3.0.0"} 2 -`), appSessionsMetric)).To(Succeed()) +`) }) - It("leaves out callers that are not the app", func() { - checkVersion("kube-probe/1.34") - checkVersion("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)") + DescribeTable("falls back to the user agent when the headers are not usable", + func(platform, version string) { + checkVersion(appRequest{platform: platform, version: version, userAgent: "Maroon Rides App (Android) v3.0.1"}) - Expect(testutil.CollectAndCount(registry, appSessionsMetric)).To(BeZero()) - }) + expectSessions(` +gateway_app_sessions_total{platform="android",version="3.0.1"} 1 +`) + }, + Entry("platform only", "web", ""), + Entry("version only", "", "3.0.2"), + Entry("unknown platform", "electron", "3.0.2"), + Entry("platform not lowercase", "iOS", "3.0.2"), + Entry("version with a v prefix", "web", "v3.0.2"), + Entry("version missing a part", "web", "3.0"), + Entry("version with a prerelease", "web", "3.0.2-beta.1"), + Entry("version that is not a number", "web", "latest"), + ) + + DescribeTable("leaves out callers that are not the app", + func(app appRequest) { + checkVersion(app) + + Expect(testutil.CollectAndCount(registry, appSessionsMetric)).To(BeZero()) + }, + Entry("kubelet probe", appRequest{userAgent: "kube-probe/1.34"}), + Entry("browser", appRequest{userAgent: browserUserAgent}), + Entry("browser with a bad platform header", appRequest{platform: "linux", version: "3.0.2", userAgent: browserUserAgent}), + Entry("browser with a bad version header", appRequest{platform: "web", version: "3", userAgent: browserUserAgent}), + ) It("folds versions past the cap into one label", func() { for patch := range 60 { - checkVersion(fmt.Sprintf("Maroon Rides App (iOS) v3.0.%d", patch)) + checkVersion(appRequest{platform: "web", version: fmt.Sprintf("3.0.%d", patch)}) } Expect(testutil.CollectAndCount(registry, appSessionsMetric)).To(Equal(51)) - checkVersion("Maroon Rides App (iOS) v3.0.0") - Expect(testutil.CollectAndCount(registry, appSessionsMetric)).To(Equal(51)) + checkVersion(appRequest{userAgent: "Maroon Rides App (iOS) v3.0.0"}) + checkVersion(appRequest{platform: "web", version: "3.0.0"}) + Expect(testutil.CollectAndCount(registry, appSessionsMetric)).To(Equal(52)) + checkVersion(appRequest{platform: "web", version: "4.0.0"}) + Expect(testutil.CollectAndCount(registry, appSessionsMetric)).To(Equal(52)) }) }) diff --git a/apps/gateway/cors_test.go b/apps/gateway/cors_test.go index fcaa83f..a0e8fb5 100644 --- a/apps/gateway/cors_test.go +++ b/apps/gateway/cors_test.go @@ -9,21 +9,34 @@ import ( ) var _ = Describe("CORS", Label("unit"), func() { + preflight := func(origin, method, headers string) *httptest.ResponseRecorder { + handler := corsMiddleware(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + req := httptest.NewRequest(http.MethodOptions, "/api/sync/stream", nil) + req.Header.Set("Origin", origin) + req.Header.Set("Access-Control-Request-Method", method) + req.Header.Set("Access-Control-Request-Headers", headers) + + res := httptest.NewRecorder() + handler.ServeHTTP(res, req) + return res + } + DescribeTable("allows any origin", func(origin string) { - handler := corsMiddleware(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) - 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") - - res := httptest.NewRecorder() - handler.ServeHTTP(res, req) + res := preflight(origin, http.MethodPost, "content-type") Expect(res.Header().Get("Access-Control-Allow-Origin")).To(Equal("*")) }, 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"), ) + + It("allows the headers the app names itself with", func() { + res := preflight("https://web.maroonrides.app", http.MethodGet, "x-app-platform,x-app-version") + + Expect(res.Header().Get("Access-Control-Allow-Origin")).To(Equal("*")) + Expect(res.Header().Get("Access-Control-Allow-Headers")).To(Equal("x-app-platform,x-app-version")) + }) }) diff --git a/apps/gateway/dtos/app_client.go b/apps/gateway/dtos/app_client.go new file mode 100644 index 0000000..90f4082 --- /dev/null +++ b/apps/gateway/dtos/app_client.go @@ -0,0 +1,8 @@ +package dtos + +// AppClient is what a request says about the app that sent it. Every field comes from the caller and is untrusted. +type AppClient struct { + Platform string + Version string + UserAgent string +} 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/report.go b/apps/gateway/services/report.go index cb451e4..6185ef3 100644 --- a/apps/gateway/services/report.go +++ b/apps/gateway/services/report.go @@ -68,27 +68,28 @@ func NewReportService(syncMetrics *metrics.SyncMetrics, reportMetrics *metrics.R } // Record drops a bad event instead of failing the request, so one event a gateway cannot read never loses the rest. -func (s *ReportService) Record(req dtos.ReportRequest, userAgent string) error { +func (s *ReportService) Record(req dtos.ReportRequest, client dtos.AppClient) error { if len(req.Events) > maxReportEvents { return fmt.Errorf("%w: %d events is more than %d", ErrInvalidReport, len(req.Events), maxReportEvents) } + clientAttr := clientLog(client) for _, event := range req.Events { - s.record(event, truncate(userAgent, maxReportedMessage)) + s.record(event, clientAttr) } return nil } -func (s *ReportService) record(event dtos.ReportEvent, userAgent string) { +func (s *ReportService) record(event dtos.ReportEvent, client slog.Attr) { decode, ok := syncFailureDecoders[event.Type] if !ok { - slog.Warn("dropped report event with unknown type", "type", truncate(string(event.Type), maxReportedMessage), "userAgent", userAgent) + slog.Warn("dropped report event with unknown type", "type", truncate(string(event.Type), maxReportedMessage), client) s.reportMetrics.Received(metrics.UnknownReportType, metrics.ReportDropped) return } failure, err := decode(event.Payload) if err != nil { - slog.Warn("dropped report event with bad payload", "type", event.Type, "error", err, "userAgent", userAgent) + slog.Warn("dropped report event with bad payload", "type", event.Type, "error", err, client) s.reportMetrics.Received(string(event.Type), metrics.ReportDropped) return } @@ -107,7 +108,7 @@ func (s *ReportService) record(event dtos.ReportEvent, userAgent string) { "status", failure.status, "message", truncate(failure.message, maxReportedMessage), "acks", failure.acks, - "userAgent", userAgent, + client, ) } @@ -117,3 +118,11 @@ func truncate(s string, limit int) string { } return s[:limit] } + +func clientLog(client dtos.AppClient) slog.Attr { + return slog.Group("client", + "platform", truncate(client.Platform, maxReportedMessage), + "version", truncate(client.Version, maxReportedMessage), + "userAgent", truncate(client.UserAgent, maxReportedMessage), + ) +} diff --git a/apps/gateway/services/session.go b/apps/gateway/services/session.go index 57b4afb..c179c51 100644 --- a/apps/gateway/services/session.go +++ b/apps/gateway/services/session.go @@ -3,13 +3,23 @@ package services import ( "regexp" + "github.com/MaroonRides/api/apps/gateway/dtos" "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 +33,23 @@ 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) { - match := appUserAgent.FindStringSubmatch(userAgent) - if match == nil { +func (s *SessionService) Started(client dtos.AppClient) { + platform, version, ok := identifyApp(client) + if !ok { return } - s.metrics.Started(appPlatforms[match[1]], match[2]) + s.metrics.Started(platform, version) +} + +func identifyApp(client dtos.AppClient) (metrics.AppPlatform, string, bool) { + platform, known := headerPlatforms[client.Platform] + if known && appVersion.MatchString(client.Version) { + return platform, client.Version, true + } + + match := appUserAgent.FindStringSubmatch(client.UserAgent) + if match == nil { + return "", "", false + } + return userAgentPlatforms[match[1]], match[2], true } From d8d7de72030b121e4baf718aec6fd178ff3a0136 Mon Sep 17 00:00:00 2001 From: bwees Date: Sat, 14 Nov 2026 13:41:27 -0600 Subject: [PATCH 2/3] refactor: move app client header parsing to gateway utils --- apps/gateway/controllers/report.go | 3 ++- apps/gateway/controllers/version.go | 3 ++- apps/gateway/{controllers => utils}/app_client.go | 4 ++-- 3 files changed, 6 insertions(+), 4 deletions(-) rename apps/gateway/{controllers => utils}/app_client.go (85%) diff --git a/apps/gateway/controllers/report.go b/apps/gateway/controllers/report.go index 126cc1f..9cf99b8 100644 --- a/apps/gateway/controllers/report.go +++ b/apps/gateway/controllers/report.go @@ -8,6 +8,7 @@ import ( "github.com/MaroonRides/api/apps/gateway/dtos" "github.com/MaroonRides/api/apps/gateway/services" + "github.com/MaroonRides/api/apps/gateway/utils" ) const maxReportBodyBytes = 256 << 10 @@ -44,7 +45,7 @@ func (c *ReportController) report(fc fuego.ContextWithBody[dtos.ReportRequest]) return nil, err } - err = c.svc.Record(req, appClient(fc.Request())) + err = c.svc.Record(req, utils.AppClient(fc.Request())) if errors.Is(err, services.ErrInvalidReport) { return nil, fuego.BadRequestError{Detail: err.Error(), Err: err} } diff --git a/apps/gateway/controllers/version.go b/apps/gateway/controllers/version.go index 383d37e..804649b 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,7 @@ 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(appClient(fc.Request())) + c.sessions.Started(utils.AppClient(fc.Request())) return SupportedVersionResponse{ Version: config.MinimumSupportedVersion, }, nil diff --git a/apps/gateway/controllers/app_client.go b/apps/gateway/utils/app_client.go similarity index 85% rename from apps/gateway/controllers/app_client.go rename to apps/gateway/utils/app_client.go index 70ec782..f1878d0 100644 --- a/apps/gateway/controllers/app_client.go +++ b/apps/gateway/utils/app_client.go @@ -1,4 +1,4 @@ -package controllers +package utils import ( "net/http" @@ -12,7 +12,7 @@ const ( appVersionHeader = "X-App-Version" ) -func appClient(r *http.Request) dtos.AppClient { +func AppClient(r *http.Request) dtos.AppClient { return dtos.AppClient{ Platform: r.Header.Get(appPlatformHeader), Version: r.Header.Get(appVersionHeader), From ee77b70ba863620258c8568cac5c93f2b3f73438 Mon Sep 17 00:00:00 2001 From: bwees Date: Sat, 14 Nov 2026 13:42:49 -0600 Subject: [PATCH 3/3] refactor: slim app session header handling --- apps/gateway/controllers/report.go | 3 +- apps/gateway/controllers/version.go | 3 +- apps/gateway/controllers/version_test.go | 121 ++++++++--------------- apps/gateway/cors_test.go | 29 ++---- apps/gateway/dtos/app_client.go | 8 -- apps/gateway/services/report.go | 21 ++-- apps/gateway/services/session.go | 21 ++-- apps/gateway/utils/app_client.go | 18 +--- 8 files changed, 69 insertions(+), 155 deletions(-) delete mode 100644 apps/gateway/dtos/app_client.go diff --git a/apps/gateway/controllers/report.go b/apps/gateway/controllers/report.go index 9cf99b8..d35e515 100644 --- a/apps/gateway/controllers/report.go +++ b/apps/gateway/controllers/report.go @@ -8,7 +8,6 @@ import ( "github.com/MaroonRides/api/apps/gateway/dtos" "github.com/MaroonRides/api/apps/gateway/services" - "github.com/MaroonRides/api/apps/gateway/utils" ) const maxReportBodyBytes = 256 << 10 @@ -45,7 +44,7 @@ func (c *ReportController) report(fc fuego.ContextWithBody[dtos.ReportRequest]) return nil, err } - err = c.svc.Record(req, utils.AppClient(fc.Request())) + err = c.svc.Record(req, fc.Request().UserAgent()) if errors.Is(err, services.ErrInvalidReport) { return nil, fuego.BadRequestError{Detail: err.Error(), Err: err} } diff --git a/apps/gateway/controllers/version.go b/apps/gateway/controllers/version.go index 804649b..d6b7b91 100644 --- a/apps/gateway/controllers/version.go +++ b/apps/gateway/controllers/version.go @@ -29,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(utils.AppClient(fc.Request())) + 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 a1421b5..fce10d4 100644 --- a/apps/gateway/controllers/version_test.go +++ b/apps/gateway/controllers/version_test.go @@ -17,20 +17,7 @@ import ( "github.com/MaroonRides/api/apps/gateway/services" ) -const ( - appSessionsMetric = "gateway_app_sessions_total" - appSessionsHeader = ` -# 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 -` - browserUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)" -) - -type appRequest struct { - platform string - version string - userAgent string -} +const appSessionsMetric = "gateway_app_sessions_total" var _ = Describe("VersionController", Label("unit"), func() { var ( @@ -45,96 +32,74 @@ var _ = Describe("VersionController", Label("unit"), func() { handler = server.Mux }) - checkVersion := func(app appRequest) { + checkVersionWithHeaders := func(platform, version, userAgent string) { req := httptest.NewRequest(http.MethodGet, "/api/version/supported", nil) - req.Header.Set("User-Agent", app.userAgent) - if app.platform != "" { - req.Header.Set("X-App-Platform", app.platform) - } - if app.version != "" { - req.Header.Set("X-App-Version", app.version) - } + 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)) } - expectSessions := func(series string) { - ExpectWithOffset(1, testutil.GatherAndCompare(registry, strings.NewReader(appSessionsHeader+series), appSessionsMetric)).To(Succeed()) + checkVersion := func(userAgent string) { + checkVersionWithHeaders("", "", userAgent) } - It("counts a session by the platform and version headers", func() { - checkVersion(appRequest{platform: "ios", version: "3.0.2"}) - checkVersion(appRequest{platform: "ios", version: "3.0.2"}) - checkVersion(appRequest{platform: "android", version: "3.0.2"}) - checkVersion(appRequest{platform: "web", version: "3.0.2", userAgent: browserUserAgent}) - - expectSessions(` -gateway_app_sessions_total{platform="android",version="3.0.2"} 1 -gateway_app_sessions_total{platform="ios",version="3.0.2"} 2 -gateway_app_sessions_total{platform="web",version="3.0.2"} 1 -`) - }) - - It("prefers the headers over the user agent", func() { - checkVersion(appRequest{platform: "android", version: "3.1.0", userAgent: "Maroon Rides App (iOS) v3.0.0"}) + 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") - expectSessions(` + 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 -`) - }) - - It("counts a build that only names itself in the user agent", func() { - checkVersion(appRequest{userAgent: "Maroon Rides App (iOS) v3.0.0"}) - checkVersion(appRequest{userAgent: "Maroon Rides App (iOS) v3.0.0"}) - checkVersion(appRequest{userAgent: "Maroon Rides App (Android) v3.0.1"}) - - expectSessions(` -gateway_app_sessions_total{platform="android",version="3.0.1"} 1 -gateway_app_sessions_total{platform="ios",version="3.0.0"} 2 -`) +gateway_app_sessions_total{platform="web",version="3.0.2"} 1 +`), appSessionsMetric)).To(Succeed()) }) - DescribeTable("falls back to the user agent when the headers are not usable", + DescribeTable("falls back to the user agent when a header is missing or malformed", func(platform, version string) { - checkVersion(appRequest{platform: platform, version: version, userAgent: "Maroon Rides App (Android) v3.0.1"}) + checkVersionWithHeaders(platform, version, "Maroon Rides App (Android) v3.0.1") - expectSessions(` + 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("platform only", "web", ""), - Entry("version only", "", "3.0.2"), + Entry("no version", "web", ""), Entry("unknown platform", "electron", "3.0.2"), - Entry("platform not lowercase", "iOS", "3.0.2"), Entry("version with a v prefix", "web", "v3.0.2"), - Entry("version missing a part", "web", "3.0"), - Entry("version with a prerelease", "web", "3.0.2-beta.1"), - Entry("version that is not a number", "web", "latest"), ) - DescribeTable("leaves out callers that are not the app", - func(app appRequest) { - checkVersion(app) + 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") + checkVersion("Maroon Rides App (Android) v3.0.1") - Expect(testutil.CollectAndCount(registry, appSessionsMetric)).To(BeZero()) - }, - Entry("kubelet probe", appRequest{userAgent: "kube-probe/1.34"}), - Entry("browser", appRequest{userAgent: browserUserAgent}), - Entry("browser with a bad platform header", appRequest{platform: "linux", version: "3.0.2", userAgent: browserUserAgent}), - Entry("browser with a bad version header", appRequest{platform: "web", version: "3", userAgent: browserUserAgent}), - ) + 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 +gateway_app_sessions_total{platform="ios",version="3.0.0"} 2 +`), appSessionsMetric)).To(Succeed()) + }) + + It("leaves out callers that are not the app", func() { + checkVersion("kube-probe/1.34") + checkVersion("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)") + + Expect(testutil.CollectAndCount(registry, appSessionsMetric)).To(BeZero()) + }) It("folds versions past the cap into one label", func() { for patch := range 60 { - checkVersion(appRequest{platform: "web", version: fmt.Sprintf("3.0.%d", patch)}) + checkVersion(fmt.Sprintf("Maroon Rides App (iOS) v3.0.%d", patch)) } Expect(testutil.CollectAndCount(registry, appSessionsMetric)).To(Equal(51)) - checkVersion(appRequest{userAgent: "Maroon Rides App (iOS) v3.0.0"}) - checkVersion(appRequest{platform: "web", version: "3.0.0"}) - Expect(testutil.CollectAndCount(registry, appSessionsMetric)).To(Equal(52)) - checkVersion(appRequest{platform: "web", version: "4.0.0"}) - Expect(testutil.CollectAndCount(registry, appSessionsMetric)).To(Equal(52)) + checkVersion("Maroon Rides App (iOS) v3.0.0") + Expect(testutil.CollectAndCount(registry, appSessionsMetric)).To(Equal(51)) }) }) diff --git a/apps/gateway/cors_test.go b/apps/gateway/cors_test.go index a0e8fb5..c980b79 100644 --- a/apps/gateway/cors_test.go +++ b/apps/gateway/cors_test.go @@ -9,34 +9,23 @@ import ( ) var _ = Describe("CORS", Label("unit"), func() { - preflight := func(origin, method, headers string) *httptest.ResponseRecorder { - handler := corsMiddleware(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) - req := httptest.NewRequest(http.MethodOptions, "/api/sync/stream", nil) - req.Header.Set("Origin", origin) - req.Header.Set("Access-Control-Request-Method", method) - req.Header.Set("Access-Control-Request-Headers", headers) - - res := httptest.NewRecorder() - handler.ServeHTTP(res, req) - return res - } - DescribeTable("allows any origin", func(origin string) { - res := preflight(origin, http.MethodPost, "content-type") + handler := corsMiddleware(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + 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,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"), ) - - It("allows the headers the app names itself with", func() { - res := preflight("https://web.maroonrides.app", http.MethodGet, "x-app-platform,x-app-version") - - Expect(res.Header().Get("Access-Control-Allow-Origin")).To(Equal("*")) - Expect(res.Header().Get("Access-Control-Allow-Headers")).To(Equal("x-app-platform,x-app-version")) - }) }) diff --git a/apps/gateway/dtos/app_client.go b/apps/gateway/dtos/app_client.go deleted file mode 100644 index 90f4082..0000000 --- a/apps/gateway/dtos/app_client.go +++ /dev/null @@ -1,8 +0,0 @@ -package dtos - -// AppClient is what a request says about the app that sent it. Every field comes from the caller and is untrusted. -type AppClient struct { - Platform string - Version string - UserAgent string -} diff --git a/apps/gateway/services/report.go b/apps/gateway/services/report.go index 6185ef3..cb451e4 100644 --- a/apps/gateway/services/report.go +++ b/apps/gateway/services/report.go @@ -68,28 +68,27 @@ func NewReportService(syncMetrics *metrics.SyncMetrics, reportMetrics *metrics.R } // Record drops a bad event instead of failing the request, so one event a gateway cannot read never loses the rest. -func (s *ReportService) Record(req dtos.ReportRequest, client dtos.AppClient) error { +func (s *ReportService) Record(req dtos.ReportRequest, userAgent string) error { if len(req.Events) > maxReportEvents { return fmt.Errorf("%w: %d events is more than %d", ErrInvalidReport, len(req.Events), maxReportEvents) } - clientAttr := clientLog(client) for _, event := range req.Events { - s.record(event, clientAttr) + s.record(event, truncate(userAgent, maxReportedMessage)) } return nil } -func (s *ReportService) record(event dtos.ReportEvent, client slog.Attr) { +func (s *ReportService) record(event dtos.ReportEvent, userAgent string) { decode, ok := syncFailureDecoders[event.Type] if !ok { - slog.Warn("dropped report event with unknown type", "type", truncate(string(event.Type), maxReportedMessage), client) + slog.Warn("dropped report event with unknown type", "type", truncate(string(event.Type), maxReportedMessage), "userAgent", userAgent) s.reportMetrics.Received(metrics.UnknownReportType, metrics.ReportDropped) return } failure, err := decode(event.Payload) if err != nil { - slog.Warn("dropped report event with bad payload", "type", event.Type, "error", err, client) + slog.Warn("dropped report event with bad payload", "type", event.Type, "error", err, "userAgent", userAgent) s.reportMetrics.Received(string(event.Type), metrics.ReportDropped) return } @@ -108,7 +107,7 @@ func (s *ReportService) record(event dtos.ReportEvent, client slog.Attr) { "status", failure.status, "message", truncate(failure.message, maxReportedMessage), "acks", failure.acks, - client, + "userAgent", userAgent, ) } @@ -118,11 +117,3 @@ func truncate(s string, limit int) string { } return s[:limit] } - -func clientLog(client dtos.AppClient) slog.Attr { - return slog.Group("client", - "platform", truncate(client.Platform, maxReportedMessage), - "version", truncate(client.Version, maxReportedMessage), - "userAgent", truncate(client.UserAgent, maxReportedMessage), - ) -} diff --git a/apps/gateway/services/session.go b/apps/gateway/services/session.go index c179c51..57cb8f2 100644 --- a/apps/gateway/services/session.go +++ b/apps/gateway/services/session.go @@ -3,7 +3,6 @@ package services import ( "regexp" - "github.com/MaroonRides/api/apps/gateway/dtos" "github.com/MaroonRides/api/apps/gateway/metrics" ) @@ -33,23 +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(client dtos.AppClient) { - platform, version, ok := identifyApp(client) - if !ok { +func (s *SessionService) Started(platform, version, userAgent string) { + if p, ok := headerPlatforms[platform]; ok && appVersion.MatchString(version) { + s.metrics.Started(p, version) return } - s.metrics.Started(platform, version) -} - -func identifyApp(client dtos.AppClient) (metrics.AppPlatform, string, bool) { - platform, known := headerPlatforms[client.Platform] - if known && appVersion.MatchString(client.Version) { - return platform, client.Version, true - } - match := appUserAgent.FindStringSubmatch(client.UserAgent) + match := appUserAgent.FindStringSubmatch(userAgent) if match == nil { - return "", "", false + return } - return userAgentPlatforms[match[1]], match[2], true + s.metrics.Started(userAgentPlatforms[match[1]], match[2]) } diff --git a/apps/gateway/utils/app_client.go b/apps/gateway/utils/app_client.go index f1878d0..72f8c16 100644 --- a/apps/gateway/utils/app_client.go +++ b/apps/gateway/utils/app_client.go @@ -1,21 +1,7 @@ package utils -import ( - "net/http" - - "github.com/MaroonRides/api/apps/gateway/dtos" -) - // 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" + AppPlatformHeader = "X-App-Platform" + AppVersionHeader = "X-App-Version" ) - -func AppClient(r *http.Request) dtos.AppClient { - return dtos.AppClient{ - Platform: r.Header.Get(appPlatformHeader), - Version: r.Header.Get(appVersionHeader), - UserAgent: r.UserAgent(), - } -}