Skip to content

Instantly share code, notes, and snippets.

@lRoMYl
Last active December 18, 2025 04:25
Show Gist options
  • Select an option

  • Save lRoMYl/b36aed7304626fb5d68d01baa4fee990 to your computer and use it in GitHub Desktop.

Select an option

Save lRoMYl/b36aed7304626fb5d68d01baa4fee990 to your computer and use it in GitHub Desktop.
Subscription Tracker
import EventTrackingMacros
// MARK: - Tracking Event
struct SubscriptionTrackingEvent: TrackingEvent {
var name: String
var eventVariables: [String: AnyObject]
}
// MARK: - Default Variables Provider
protocol DefaultEventVariablesProviding {
var eventVariables: [String: AnyObject] { get }
}
// MARK: - Event Tracking Protocol
protocol EventTracking {
func track<V: Encodable>(type: TypedEvent<V>, variables: V)
}
// MARK: - Event Tracker
final class EventTracker: EventTracking {
private let tracker: Tracking?
private let defaultEventVariablesProvider: DefaultEventVariablesProviding?
init(tracker: Tracking? = nil, defaultEventVariablesProvider: DefaultEventVariablesProviding? = nil) {
self.tracker = tracker
self.defaultEventVariablesProvider = defaultEventVariablesProvider
}
func track<V: Encodable>(type: TypedEvent<V>, variables: V) {
guard
let data = try? JSONEncoder().encode(variables),
let eventVariables = try? JSONSerialization.jsonObject(with: data) as? [String: AnyObject]
else { return }
let mergedVariables = (defaultEventVariablesProvider?.eventVariables ?? [:])
.merging(eventVariables) { _, provided in provided }
tracker?.trackEvent(
SubscriptionTrackingEvent(
name: type.eventType,
eventVariables: mergedVariables
)
)
}
}
// MARK: - Encodable Macro
/// Marks a struct as encodable for tracking events, generating:
/// - Encodable conformance (if not already present)
/// - encode(to:) method using the provided CodingKey type
///
/// Usage:
/// ```swift
/// @TrackingEncodable(EventKeyString.self)
/// struct OneClickEventVariable {
/// let plan: String
/// let origin: String
/// }
/// ```
///
/// Property annotations:
/// - @TrackingKey: Override the CodingKey for a property
/// - @TrackingIgnored: Exclude a property from encoding
@attached(extension, conformances: Encodable)
@attached(member, names: named(encode(to:)))
public macro TrackingEncodable<K: CodingKey>(
_ keyType: K.Type
) = #externalMacro(
module: "EventTrackingMacrosPlugin",
type: "TrackingEncodableMacro"
)
// MARK: - Event Registration
/// Registers events and generates static properties on TypedEvent.
///
/// Usage:
/// ```swift
/// @TrackingRegistry(eventType: EventTypeString.self, registrations: [
/// ([.oneClickClicked, .oneClickOverlayLoaded], OneClickEventVariable.self),
/// ([.subscriptionCompleted], SubscriptionCompletedEventVariable.self)
/// ])
/// struct TypedEvent<V: Encodable> { ... }
/// ```
///
/// Generates:
/// ```swift
/// extension TypedEvent {
/// static var oneClickClicked: TypedEvent<OneClickEventVariable> { .init(EventTypeString.oneClickClicked) }
/// static var oneClickOverlayLoaded: TypedEvent<OneClickEventVariable> { .init(EventTypeString.oneClickOverlayLoaded) }
/// static var subscriptionCompleted: TypedEvent<SubscriptionCompletedEventVariable> { .init(EventTypeString.subscriptionCompleted) }
/// }
/// ```
@attached(member, names: arbitrary)
public macro TrackingRegistry<E: RawRepresentable>(
eventType: E.Type,
registrations: [([E], Any.Type)]
) = #externalMacro(
module: "EventTrackingMacrosPlugin",
type: "TrackingRegistryMacro"
) where E.RawValue == String
// MARK: - Property Annotations
/// Overrides the CodingKey for a property.
///
/// The key type should match the key type used in the parent's @TrackingEncodable.
/// Using a mismatched key type will result in a compile error.
///
/// Example:
/// ```swift
/// @TrackingEncodable(EventKeyString.self)
/// struct MyVariables {
/// @TrackingKey(EventKeyString.cashBackAmount)
/// let amount: Double // encodes using .cashBackAmount key
///
/// @TrackingKey("custom_key")
/// let value: String // encodes using .custom_key
/// }
/// ```
@attached(peer)
public macro TrackingKey<K: CodingKey>(_ key: K) = #externalMacro(
module: "EventTrackingMacrosPlugin",
type: "TrackingKeyMacro"
)
/// Overrides the CodingKey for a property using a raw string.
@attached(peer)
public macro TrackingKey(_ key: String) = #externalMacro(
module: "EventTrackingMacrosPlugin",
type: "TrackingKeyMacro"
)
/// Excludes a property from the generated encode(to:) method.
///
/// Example:
/// ```swift
/// @TrackingEncodable(EventKeyString.self)
/// struct MyVariables {
/// let trackedValue: String
///
/// @TrackingIgnored
/// let internalState: String // not encoded
/// }
/// ```
@attached(peer)
public macro TrackingIgnored() = #externalMacro(
module: "EventTrackingMacrosPlugin",
type: "TrackingIgnoredMacro"
)
import Foundation
import SwiftCompilerPlugin
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros
// MARK: - TrackingEncodable Macro
public struct TrackingEncodableMacro: ExtensionMacro, MemberMacro {
// MARK: - Extension Macro (adds Encodable conformance)
public static func expansion(
of node: AttributeSyntax,
attachedTo declaration: some DeclGroupSyntax,
providingExtensionsOf type: some TypeSyntaxProtocol,
conformingTo protocols: [TypeSyntax],
in context: some MacroExpansionContext
) throws -> [ExtensionDeclSyntax] {
// Check if the type already conforms to Encodable
let alreadyConformsToEncodable = declaration.inheritanceClause?.inheritedTypes.contains { inherited in
let typeName = inherited.type.description.trimmingCharacters(in: CharacterSet.whitespaces)
return typeName == "Encodable" || typeName == "Codable"
} ?? false
if alreadyConformsToEncodable {
return []
}
let encodableExtension = try ExtensionDeclSyntax("extension \(type): Encodable {}")
return [encodableExtension]
}
// MARK: - Member Macro (adds encode(to:) method)
public static func expansion(
of node: AttributeSyntax,
providingMembersOf declaration: some DeclGroupSyntax,
conformingTo protocols: [TypeSyntax],
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
let codingKeyType = try parseKeyType(from: node)
// Generate encode(to:) method
if let encodeMethod = try generateEncodeMethod(
keyTypeName: codingKeyType,
declaration: declaration
) {
return [encodeMethod]
}
return []
}
// MARK: - Argument Parsing
private static func parseKeyType(from node: AttributeSyntax) throws -> String {
guard let arguments = node.arguments?.as(LabeledExprListSyntax.self),
let firstArg = arguments.first else {
throw MacroError.message("@TrackingEncodable requires a CodingKey type argument")
}
return extractTypeName(from: firstArg.expression)
}
private static func extractTypeName(from expr: ExprSyntax) -> String {
let exprText = expr.description.trimmingCharacters(in: CharacterSet.whitespaces)
if exprText.hasSuffix(".self") {
return String(exprText.dropLast(5))
}
return exprText
}
// MARK: - Encode Method Generation
private static func generateEncodeMethod(
keyTypeName: String,
declaration: some DeclGroupSyntax
) throws -> DeclSyntax? {
let members = declaration.memberBlock.members
var encodingStatements: [String] = []
for member in members {
guard let varDecl = member.decl.as(VariableDeclSyntax.self),
let binding = varDecl.bindings.first,
let identifier = binding.pattern.as(IdentifierPatternSyntax.self) else {
continue
}
let propertyName = identifier.identifier.text
// Check for @TrackingIgnored
let hasIgnoredAttribute = varDecl.attributes.contains { attr in
guard let attrSyntax = attr.as(AttributeSyntax.self) else { return false }
let name = attrSyntax.attributeName.description.trimmingCharacters(in: CharacterSet.whitespaces)
return name == "TrackingIgnored"
}
if hasIgnoredAttribute {
continue
}
// Check for @TrackingKey override, otherwise use property name
let keyName: String
let trackingKeyAttr = varDecl.attributes.first { attr in
guard let attrSyntax = attr.as(AttributeSyntax.self) else { return false }
let name = attrSyntax.attributeName.description.trimmingCharacters(in: CharacterSet.whitespaces)
return name == "TrackingKey"
}?.as(AttributeSyntax.self)
if let trackingKeyAttr = trackingKeyAttr,
let keyArguments = trackingKeyAttr.arguments?.as(LabeledExprListSyntax.self),
let keyArg = keyArguments.first {
// Check if it's a string literal
if let stringLiteral = keyArg.expression.as(StringLiteralExprSyntax.self),
let segment = stringLiteral.segments.first?.as(StringSegmentSyntax.self) {
keyName = segment.content.text
} else {
// CodingKey case expression (e.g., EventKeyString.cashBackAmount or .cashBackAmount)
let keyExpression = keyArg.expression.description.trimmingCharacters(in: CharacterSet.whitespaces)
if keyExpression.contains(".") {
let components = keyExpression.split(separator: ".")
keyName = components.last.map(String.init) ?? propertyName
} else {
keyName = keyExpression
}
}
} else {
keyName = propertyName
}
encodingStatements.append("try container.encode(\(propertyName), forKey: .\(keyName))")
}
guard !encodingStatements.isEmpty else {
return nil
}
let encodeMethod = try FunctionDeclSyntax(
"""
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: \(raw: keyTypeName).self)
\(raw: encodingStatements.joined(separator: "\n "))
}
"""
)
return DeclSyntax(encodeMethod)
}
}
// MARK: - TrackingRegistry Macro
public struct TrackingRegistryMacro: MemberMacro {
public static func expansion(
of node: AttributeSyntax,
providingMembersOf declaration: some DeclGroupSyntax,
conformingTo protocols: [TypeSyntax],
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
guard let arguments = node.arguments?.as(LabeledExprListSyntax.self) else {
throw MacroError.message("@TrackingRegistry requires arguments")
}
var eventTypeName: String?
var registrations: [(variablesType: String, events: [String])] = []
for arg in arguments {
let label = arg.label?.text
if label == "eventType" {
let eventTypeExpr = arg.expression.description.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
if eventTypeExpr.hasSuffix(".self") {
eventTypeName = String(eventTypeExpr.dropLast(5))
} else {
eventTypeName = eventTypeExpr
}
} else if label == "registrations" {
registrations = parseRegistrationsArray(from: arg.expression)
}
}
guard let eventTypeName else {
throw MacroError.message("@TrackingRegistry requires eventType argument")
}
guard !registrations.isEmpty else {
throw MacroError.message("@TrackingRegistry requires at least one registration")
}
// Generate static properties for each event
var declarations: [DeclSyntax] = []
for registration in registrations {
for event in registration.events {
let propertyDecl: DeclSyntax =
"static var \(raw: event): TypedEvent<\(raw: registration.variablesType)> { .init(\(raw: eventTypeName).\(raw: event)) }"
declarations.append(propertyDecl)
}
}
return declarations
}
private static func parseRegistrationsArray(
from expr: ExprSyntax
) -> [(variablesType: String, events: [String])] {
var registrations: [(variablesType: String, events: [String])] = []
// Parse array literal: [tuple1, tuple2, ...]
guard let arrayExpr = expr.as(ArrayExprSyntax.self) else {
return registrations
}
for element in arrayExpr.elements {
if let registration = parseTupleRegistration(from: element.expression) {
registrations.append(registration)
}
}
return registrations
}
private static func parseTupleRegistration(
from expr: ExprSyntax
) -> (variablesType: String, events: [String])? {
// Parse tuple: ([.event1, .event2], VariablesType.self)
guard let tupleExpr = expr.as(TupleExprSyntax.self) else {
return nil
}
let elements = Array(tupleExpr.elements)
guard elements.count == 2 else {
return nil
}
// First element: array of events
let events = parseEventsArray(from: elements[0].expression)
// Second element: variables type
var variablesType = elements[1].expression.description.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
if variablesType.hasSuffix(".self") {
variablesType = String(variablesType.dropLast(5))
}
guard !events.isEmpty else {
return nil
}
return (variablesType: variablesType, events: events)
}
private static func parseEventsArray(from expr: ExprSyntax) -> [String] {
guard let arrayExpr = expr.as(ArrayExprSyntax.self) else {
return []
}
var events: [String] = []
for element in arrayExpr.elements {
let exprText = element.expression.description.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines)
let caseName = extractEventCaseName(from: exprText)
events.append(caseName)
}
return events
}
private static func extractEventCaseName(from exprText: String) -> String {
if exprText.hasPrefix(".") {
return String(exprText.dropFirst())
} else if exprText.contains(".") {
let components = exprText.split(separator: ".")
return components.last.map(String.init) ?? exprText
}
return exprText
}
}
// MARK: - TrackingKey Macro (marker only)
public struct TrackingKeyMacro: PeerMacro {
public static func expansion(
of node: AttributeSyntax,
providingPeersOf declaration: some DeclSyntaxProtocol,
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
return []
}
}
// MARK: - TrackingIgnored Macro (marker only)
public struct TrackingIgnoredMacro: PeerMacro {
public static func expansion(
of node: AttributeSyntax,
providingPeersOf declaration: some DeclSyntaxProtocol,
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
return []
}
}
// MARK: - Macro Error
enum MacroError: Error, CustomStringConvertible {
case message(String)
var description: String {
switch self {
case .message(let text):
return text
}
}
}
// MARK: - Plugin Registration
@main
struct EventTrackingMacrosPlugin: CompilerPlugin {
let providingMacros: [Macro.Type] = [
TrackingEncodableMacro.self,
TrackingRegistryMacro.self,
TrackingKeyMacro.self,
TrackingIgnoredMacro.self
]
}
// MARK: - Interactor Usage
final class OneClickInteractor {
private let tracker: EventTracking
init(tracker: EventTracking) {
self.tracker = tracker
}
func trackDemo() {
// Type-safe: .subscriptionCompleted only works with SubscriptionCompletedEventVariables
customTracker.track(
type: .subscriptionCompleted,
variables: SubscriptionCompletedEventVariable(
entryPoint: .RDPOfferCarousel,
subscriptionFlow: .oneClickActiveCancelled
)
)
// Type-safe: .oneClickClicked only works with OneClickEventVariable
customTracker.track(
type: .oneClickClicked,
variables: OneClickEventVariable(
plan: "some plan",
origin: "some origin",
cashBackAmount: 10.0,
screenName: .benefitDetailsScreen,
screenType: .subscription
)
)
}
}
enum EventKeyString: String, CodingKey {
case entryPoint = "entry_point"
case cashBackAmount = "cash_back_amount"
case paymentMethod = "payment_method"
case subscriptionFlow = "subscription_flow"
case plan
case origin
case screenName = "screen_name"
case screenType = "screen_type"
}
enum EventTypeString: String {
case oneClickClicked = "one_click_clicked"
case oneClickOverlayLoaded = "one_click_overlay_loaded"
case subscriptionCompleted = "subscription_completed"
}
// MARK: - TypedEvent
/// Register EventType to EventVariables for autocompletion
@TrackingRegistry(eventType: EventTypeString.self, registrations: [
([.oneClickClicked, .oneClickOverlayLoaded], OneClickEventVariable.self),
([.subscriptionCompleted], SubscriptionCompletedEventVariable.self)
])
/// Generic event wrapper that binds an event type to a variables type.
struct TypedEvent<V: Encodable> {
let eventType: String
init(eventType: String) {
self.eventType = eventType
}
init<E: RawRepresentable>(_ eventType: E) where E.RawValue == String {
self.eventType = eventType.rawValue
}
}
// MARK: - Generic Event Variable Schema
protocol ScreenEventVariableSchema {
var screenName: EventScreenName { get }
var screenType: EventScreenType { get }
}
// MARK: - OneClickEventVariable
@TrackingEncodable(EventKeyString.self)
struct OneClickEventVariable: ScreenEventVariableSchema {
let plan: String
let origin: String
let cashBackAmount: Double?
var screenName: EventScreenName = .subEnrolmentOverlay
let screenType: EventScreenType
}
// MARK: - SubscriptionCompletedEventVariable
@TrackingEncodable(EventKeyString.self)
struct SubscriptionCompletedEventVariable {
// Key overrides
@TrackingKey(EventKeyString.cashBackAmount)
let entryPoint: SubscriptionEntryPointType
let subscriptionFlow: SubscriptionFlow?
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment