Last active
December 18, 2025 04:25
-
-
Save lRoMYl/b36aed7304626fb5d68d01baa4fee990 to your computer and use it in GitHub Desktop.
Subscription Tracker
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | |
| ) | |
| ) | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // 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" | |
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | |
| ] | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // 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 | |
| ) | |
| ) | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // 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