Added the Event type to the Analytics type in the Infrastructure package.

This commit is contained in:
2026-08-15 11:31:59 +02:00
parent a5305c6044
commit 844a85f88c
4 changed files with 206 additions and 40 deletions
@@ -1,16 +1,10 @@
import Foundation
/// The analytics tracker a page embeds: where the script loads from, which site it reports as, which domains it reports from, and how it behaves.
/// The Umami analytics tracker a page embeds as a deferred `<script>` in its document head.
///
/// A page carries it as an optional value so the ``Page`` scaffolding renders the tracker's deferred `<script>` in the document head, or omits it
/// when the page has none. The attribute names follow the Umami tracker convention. The three behavior flags default to on, and each renders its
/// `data-` attribute only when enabled, since the tracker treats an absent attribute as off. Recorder mode, by contrast, defaults to off; when
/// activated, the page embeds a second deferred script that loads the session recorder from the tracker's origin.
/// The behavior flags default to on and render their `data-` attributes only when enabled, since the tracker treats an absent attribute as off.
/// Recorder mode defaults to off; when on, the page embeds a second deferred script loading the session recorder from the tracker's origin.
public struct Analytics: Sendable {
// MARK: Type aliases
public typealias Attribute = (name: String, value: String)
// MARK: Properties
@@ -66,47 +60,30 @@ public struct Analytics: Sendable {
// MARK: Computed
/// The tracker script's attributes, in a stable order: the website id and the reporting domains, then each enabled behavior flag.
///
/// A disabled flag is left out entirely, since the tracker treats an absent attribute as off. Each `name` is a full attribute name following the
/// Umami `data-` convention, which a page applies to the deferred script verbatim so the page renders the tracker without knowing its shape.
/// The tracker script's attributes: the website id and reporting domains, then each enabled behavior flag; disabled flags are omitted.
public var attributes: [Attribute] {
var attributes = [(
name: "data-website-id",
value: websiteID
), (
name: "data-domains",
value: domains
)]
var attributes: [Attribute] = [
.init("data-website-id", value: websiteID),
.init("data-domains", value: domains)
]
if excludeHash {
attributes.append((
name: "data-exclude-hash",
value: "true"
))
attributes.append(.init("data-exclude-hash", value: "true"))
}
if doNotTrack {
attributes.append((
name: "data-do-not-track",
value: "true"
))
attributes.append(.init("data-do-not-track", value: "true"))
}
if performance {
attributes.append((
name: "data-performance",
value: "true"
))
attributes.append(.init("data-performance", value: "true"))
}
return attributes
}
/// The origin the tracker is served from (scheme and host, plus any explicit port), derived from the ``scriptURL``, or `nil` when the
/// URL carries no scheme or host.
///
/// A page renders it as a `preconnect` hint before the tracker script, so the connection handshake starts as early as possible.
/// The origin the tracker is served from (scheme, host, and any explicit port), derived from the ``scriptURL``; `nil` when the URL
/// carries no scheme or host. Rendered as a `preconnect` hint before the tracker script.
public var origin: String? {
guard
let url = URL(string: scriptURL),
@@ -121,10 +98,8 @@ public struct Analytics: Sendable {
return "\(scheme)://\(host)\(port)"
}
/// The URL the session recorder script is loaded from, derived from the tracker's ``origin``, or `nil` when ``recorder`` mode is off
/// or no origin can be derived from the ``scriptURL``.
///
/// A page renders it as a second deferred script tag after the tracker script, carrying only the `data-website-id` attribute.
/// The URL the session recorder script is loaded from; `nil` when ``recorder`` mode is off or no ``origin`` can be derived.
/// Rendered as a second deferred script carrying only the `data-website-id` attribute.
public var recorderScriptURL: String? {
guard recorder, let origin else {
return nil
@@ -0,0 +1,31 @@
extension Analytics {
/// A name-value pair rendered as an HTML attribute.
///
/// The ``name`` is a full `data-` attribute name applied verbatim except inside ``Analytics/Event/properties``, where it is the bare
/// key the event prefixes on render.
public struct Attribute: Equatable, Sendable {
// MARK: Properties
/// The attribute's name.
public let name: String
/// The attribute's value.
public let value: String
// MARK: Initializers
/// Creates a tracker attribute.
/// - Parameters:
/// - name: the attribute's name.
/// - value: the attribute's value.
public init(
_ name: String,
value: String
) {
self.name = name
self.value = value
}
}
}
@@ -0,0 +1,55 @@
extension Analytics {
/// An interaction a page reports to the tracker, rendered as `data-` attributes on the element that carries it.
///
/// The tracker records a click on any element carrying `data-umami-event`; each `data-umami-event-<name>` beside it becomes a property
/// the report can be broken down by. Reports group by ``name``, so related interactions should share one name and differ by a property.
public struct Event: Equatable, Sendable {
// MARK: Properties
/// The event's name, which is what a report groups by.
public let name: String
/// The event's properties, in declaration order, which is the order they render.
///
/// Each ``Attribute/name`` is the bare key `set`, not `data-umami-event-set`; ``attributes`` adds the prefix.
public let properties: [Attribute]
// MARK: Initializers
/// Creates an event.
/// - Parameters:
/// - name: the event's name, which is what a report groups by.
/// - properties: the event's properties as bare keys `set`, not `data-umami-event-set` rendered in the order written.
/// Empty by default.
public init(
name: String,
properties: KeyValuePairs<String, String> = [:]
) {
self.name = name
self.properties = properties.map {
.init($0.key, value: $0.value)
}
}
// MARK: Computed
/// The event's attributes the event name, then each property as full `data-` attribute names an element applies verbatim.
public var attributes: [Attribute] {
[.init(Constant.Name.prefix, value: name)]
+ properties.map {
.init("\(Constant.Name.prefix)-\($0.name)", value: $0.value)
}
}
}
}
// MARK: - Constants
private enum Constant {
enum Name {
/// The attribute name the tracker watches for, and the prefix each property renders under.
static let prefix = "data-umami-event"
}
}
@@ -0,0 +1,105 @@
import Testing
@testable import Infrastructure
@Suite(
"Analytics.Event type",
.tags(.type)
)
struct AnalyticsEventTests {
// MARK: Computed tests
@Test
func `renders the event name alone when it carries no properties`() {
let event = Analytics.Event(name: "instagram")
#expect(event.attributes.count == 1)
#expect(event.attributes[0] == Analytics.Attribute(
"data-umami-event",
value: "instagram"
))
}
@Test
func `renders each property as an attribute suffixed by its key`() {
let event = Analytics.Event(
name: "playlist",
properties: ["set": "avc-xi"]
)
#expect(event.attributes.count == 2)
#expect(event.attributes[1] == Analytics.Attribute(
"data-umami-event-set",
value: "avc-xi"
))
}
@Test
func `renders the name first, then the properties in the order given`() {
let event = Analytics.Event(
name: "interview",
properties: [
"placement": "credit",
"locale": "en",
]
)
// The order is the rendered attribute order, which a dictionary would leave to churn between builds.
#expect(event.attributes.map(\.name) == [
"data-umami-event",
"data-umami-event-placement",
"data-umami-event-locale",
])
#expect(event.attributes.map(\.value) == ["interview", "credit", "en"])
}
// MARK: Equatable tests
@Test
func `matches an event rendering the same attributes`() {
let event = Analytics.Event(
name: "playlist",
properties: ["set": "avc-xi"]
)
#expect(event == Analytics.Event(
name: "playlist",
properties: ["set": "avc-xi"]
))
}
@Test(arguments: [
Analytics.Event(
name: "playlist",
properties: ["set": "avc-ix"]
),
Analytics.Event(
name: "playlist",
properties: ["show": "avc-xi"]
),
Analytics.Event(
name: "apple_music",
properties: ["set": "avc-xi"]
),
Analytics.Event(name: "playlist"),
Analytics.Event(
name: "playlist",
properties: [
"set": "avc-xi",
"placement": "strip",
]
),
])
func `differs from an event rendering anything else`(
from other: Analytics.Event
) {
let event = Analytics.Event(
name: "playlist",
properties: ["set": "avc-xi"]
)
#expect(event != other)
}
}