From 397ad6b896d6fb33038d176beba20560c437dc51 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Thu, 13 Aug 2026 01:24:11 +0200 Subject: [PATCH] Added the "analytics.recorder" flag to the Website service. --- Services/Website/README.md | 1 + .../Extensions/ConfigReader+Properties.swift | 37 ++++++++++ .../AbsoluteConfigKey+Constants.swift | 9 +++ .../Extensions/ConfigKey+Constants.swift | 9 +++ .../Public/Extensions/String+Constants.swift | 17 +++++ .../App/ConfigReaderPropertiesTests.swift | 70 +++++++++++++++++++ 6 files changed, 143 insertions(+) create mode 100644 Services/Website/Tests/App/ConfigReaderPropertiesTests.swift diff --git a/Services/Website/README.md b/Services/Website/README.md index 55b2151..d3d107d 100644 --- a/Services/Website/README.md +++ b/Services/Website/README.md @@ -139,6 +139,7 @@ See [Persistence](#persistence-1) below for the workflow. | --- | --- | --- | --- | | `analytics.websiteID` | `ANALYTICS_WEBSITE_ID` | `f28681d6-20e8-43f3-9c3b-5d6a0f8e0591` | The analytics website identifier the tracker on both pages reports as. **Set it to an empty string to disable analytics entirely** — the tracker script is then omitted from the pages. | | `analytics.domains` | `ANALYTICS_DOMAINS` | `loud.amsterdam` | Comma-delimited domains the tracker reports from; visits from any other host (development, staging) are ignored. | +| `analytics.recorder` | `ANALYTICS_RECORDER` | `true` | Whether the pages also embed the session recorder script (`recorder.js`, loaded from the tracker's origin) alongside the tracker. Set it to `false` to disable session recording on a deployment. | The tracker's origin (`https://analytics.rock-n-code.com`) is not configurable: it is single-sourced in code so the tracker tag and the `Content-Security-Policy` that must allow it (`security.contentSecurityPolicy` below) always agree. The pages also emit a `preconnect` hint for it, so the cross-origin handshake starts before the parser reaches the deferred tracker script. diff --git a/Services/Website/Sources/App/Extensions/ConfigReader+Properties.swift b/Services/Website/Sources/App/Extensions/ConfigReader+Properties.swift index bfadf67..adef521 100644 --- a/Services/Website/Sources/App/Extensions/ConfigReader+Properties.swift +++ b/Services/Website/Sources/App/Extensions/ConfigReader+Properties.swift @@ -14,6 +14,43 @@ package extension ConfigReader { // MARK: Computed + /// The analytics tracker the landing page embeds, built from the `analytics.*` keys, or `nil` when `analytics.websiteID` resolves + /// empty — a deployment disables analytics entirely by clearing the identifier. + /// + /// The script URL is not configurable: its origin is single-sourced in `String.Analytics`, so the tracker tag and the + /// `Content-Security-Policy` that must allow it derive from one constant and cannot drift apart. + /// + /// The `analytics.domains` filter must name the host the pages are served from — i.e. the host of ``siteOrigin``. The two keys are + /// independent, so a deployment that overrides `site.origin` without matching `analytics.domains` reports from a host it no longer + /// serves and records nothing; change them together. + /// + /// Recorder mode is on by default — the pages embed the session recorder script alongside the tracker — and the `analytics.recorder` + /// flag turns it off for a deployment. The recorder loads from the same origin as the tracker, so the `Content-Security-Policy` needs + /// no extra allowance. + var analytics: Analytics? { + let websiteID = string( + forKey: .Analytics.websiteID, + default: .Analytics.websiteID + ) + + guard !websiteID.isEmpty else { + return nil + } + + return .init( + scriptURL: .Analytics.scriptURL, + websiteID: websiteID, + domains: string( + forKey: .Analytics.domains, + default: .Analytics.domains + ), + recorder: bool( + forKey: .Analytics.recorder, + default: true + ) + ) + } + /// The `Cache-Control` policy applied to static files, grouped by media type. /// /// The max-ages are read from the `cache.maxAge.asset`, `cache.maxAge.text`, `cache.maxAge.image`, and diff --git a/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift index 8cd3628..62dd206 100644 --- a/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift +++ b/Services/Website/Sources/Library/Public/Extensions/AbsoluteConfigKey+Constants.swift @@ -1,6 +1,15 @@ import Configuration extension AbsoluteConfigKey { + /// A namespace for the analytics configuration keys, as absolute keys. + public enum Analytics { + /// The absolute configuration key for the analytics website identifier. + public static let websiteID: AbsoluteConfigKey = .init(.Analytics.websiteID) + /// The absolute configuration key for the comma-delimited domains the tracker reports from. + public static let domains: AbsoluteConfigKey = .init(.Analytics.domains) + /// The absolute configuration key for recorder mode, loading the session recorder script alongside the tracker. + public static let recorder: AbsoluteConfigKey = .init(.Analytics.recorder) + } /// A namespace for the static files cache configuration keys, as absolute keys. public enum Cache { /// The absolute configuration key for the max-age, in seconds, applied to fingerprinted assets and fonts. diff --git a/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift index 646a9da..9fcddaa 100644 --- a/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift +++ b/Services/Website/Sources/Library/Public/Extensions/ConfigKey+Constants.swift @@ -1,6 +1,15 @@ import Configuration extension ConfigKey { + /// A namespace for the analytics configuration keys. + public enum Analytics { + /// The configuration key for the analytics website identifier (cleared to disable analytics). + public static let websiteID: ConfigKey = "analytics.websiteID" + /// The configuration key for the comma-delimited domains the tracker reports from. + public static let domains: ConfigKey = "analytics.domains" + /// The configuration key for recorder mode, loading the session recorder script alongside the tracker (set to `false` to disable). + public static let recorder: ConfigKey = "analytics.recorder" + } /// A namespace for the static files cache configuration keys. public enum Cache { /// The configuration key for the max-age, in seconds, applied to fingerprinted assets (CSS, JavaScript) and fonts. diff --git a/Services/Website/Sources/Library/Public/Extensions/String+Constants.swift b/Services/Website/Sources/Library/Public/Extensions/String+Constants.swift index 66940f5..5da47ee 100644 --- a/Services/Website/Sources/Library/Public/Extensions/String+Constants.swift +++ b/Services/Website/Sources/Library/Public/Extensions/String+Constants.swift @@ -1,4 +1,21 @@ extension String { + /// A namespace for the analytics default configuration values. + public enum Analytics { + /// The origin the analytics scripts are loaded from and their beacons are sent to (scheme and host, no trailing slash). + /// + /// Single-sourced here: both ``scriptURL`` and the session recorder script the pages embed in recorder mode derive from this + /// constant, and the site's `Content-Security-Policy` must allow it. + public static let origin = "https://analytics.rock-n-code.com" + /// The URL the analytics tracker script is loaded from. + public static let scriptURL = "\(origin)/script" + /// The default analytics website identifier the tracker reports as. + public static let websiteID = "f28681d6-20e8-43f3-9c3b-5d6a0f8e0591" + /// The default comma-delimited domains the tracker reports from; visits from any other host are ignored. + /// + /// Keep it paired with the host the pages are served at: a deployment that serves from another host without overriding + /// `analytics.domains` to match reports from a host it no longer serves, so analytics silently records nothing. + public static let domains = "loud.amsterdam" + } /// A namespace for the persistence's default configuration values and recognized tokens. public enum Database { /// The default persistence driver: in-memory SQLite, which needs no external infrastructure. diff --git a/Services/Website/Tests/App/ConfigReaderPropertiesTests.swift b/Services/Website/Tests/App/ConfigReaderPropertiesTests.swift new file mode 100644 index 0000000..6698938 --- /dev/null +++ b/Services/Website/Tests/App/ConfigReaderPropertiesTests.swift @@ -0,0 +1,70 @@ +import Configuration +import Infrastructure +import Persistence +import Testing + +@testable import Website +@testable import WebsiteLibrary + +@Suite("ConfigReader properties") +struct ConfigReaderPropertiesTests { + + // MARK: Functional tests + + @Test + func `analytics to default to the production tracker`() throws { + let analytics = try #require(reader().analytics) + + #expect(analytics.scriptURL == .Analytics.scriptURL) + #expect(analytics.websiteID == .Analytics.websiteID) + #expect(analytics.domains == .Analytics.domains) + #expect(analytics.excludeHash) + #expect(analytics.doNotTrack) + #expect(analytics.performance) + #expect(analytics.recorder) + } + + @Test + func `analytics to switch recorder mode off when configured`() throws { + let analytics = try #require(reader(values: [ + .Analytics.recorder: false + ]).analytics) + + #expect(!analytics.recorder) + } + + @Test + func `analytics to override the website id and domains when configured`() throws { + let analytics = try #require(reader(values: [ + .Analytics.websiteID: "custom-website-id", + .Analytics.domains: "staging.loud.amsterdam" + ]).analytics) + + #expect(analytics.websiteID == "custom-website-id") + #expect(analytics.domains == "staging.loud.amsterdam") + #expect(analytics.scriptURL == .Analytics.scriptURL) + } + + @Test + func `analytics to be omitted when the website id is cleared`() { + #expect(reader(values: [.Analytics.websiteID: ""]).analytics == nil) + } + +} + +// MARK: - Helpers + +private extension ConfigReaderPropertiesTests { + + // MARK: Methods + + /// Builds a configuration reader over the given in-memory values alone. + func reader( + values: [AbsoluteConfigKey: ConfigValue] = [:] + ) -> ConfigReader { + ConfigReader(providers: [ + InMemoryProvider(values: values) + ]) + } + +}