├── .github └── workflows │ └── swift.yml ├── .gitignore ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── LICENSE ├── Package.swift ├── README.md ├── Sources └── PlausibleSwift │ └── PlausibleSwift.swift └── Tests └── PlausibleSwiftTests └── PlausibleSwiftTests.swift /.github/workflows/swift.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a Swift project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift 3 | 4 | name: Swift 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: macos-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Build 20 | run: swift build -v 21 | - name: Run tests 22 | run: swift test -v 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Nick O'Neill 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.4 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "PlausibleSwift", 8 | products: [ 9 | // Products define the executables and libraries a package produces, and make them visible to other packages. 10 | .library( 11 | name: "PlausibleSwift", 12 | targets: ["PlausibleSwift"]), 13 | ], 14 | dependencies: [ 15 | // Dependencies declare other packages that this package depends on. 16 | // .package(url: /* package url */, from: "1.0.0"), 17 | ], 18 | targets: [ 19 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 20 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 21 | .target( 22 | name: "PlausibleSwift", 23 | dependencies: []), 24 | .testTarget( 25 | name: "PlausibleSwiftTests", 26 | dependencies: ["PlausibleSwift"]), 27 | ] 28 | ) 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PlausibleSwift 2 | 3 | An implementation of [Plausible Analytics pageview and event tracking](https://plausible.io/docs/events-api) for Swift. Originally created for the [5 Calls](https://github.com/5calls/ios) companion app. 4 | 5 | ### Usage 6 | 7 | Configure a site that is connected to Plausible, then track a pageview event: 8 | 9 | ```Swift 10 | let plausible = PlausibleSwift(domain: "example.site") 11 | plausible.trackPageview(path: "/") 12 | ``` 13 | 14 | Or, track an arbitrary event: 15 | 16 | ```Swift 17 | plausible.trackEvent(event: "clicked-donate", path: "/donate") 18 | ``` 19 | -------------------------------------------------------------------------------- /Sources/PlausibleSwift/PlausibleSwift.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// PlausibleSwift is an implementation of the Plausible Analytics REST events API as described here: https://plausible.io/docs/events-api 4 | public struct PlausibleSwift { 5 | public private(set) var domain = "" 6 | 7 | private let PlausibleAPIEventURL = URL(string: "https://plausible.io/api/event")! 8 | 9 | /// Initializes a plausible object used for sending events to Plausible.io 10 | /// Throws a `invalidDomain` error if the domain you pass cannot be turned into a URL 11 | /// - Parameters: 12 | /// - domain: a fully qualified domain representing a site you have set up on plausible.io, such as `5calls.org` 13 | public init(domain: String) throws { 14 | // try to craft a URL out of our domain to ensure correctness 15 | guard let _ = URL(string: "https://\(domain)") else { 16 | throw PlausibleError.invalidDomain 17 | } 18 | 19 | self.domain = domain 20 | } 21 | 22 | /// Sends a pageview event to Plausible for the specified path 23 | /// - Parameters: 24 | /// - path: a URL path to use as the pageview location (as if it was viewed on a website). There doesn't have to be anything served at this URL. 25 | /// - properties: (optional) a dictionary of key-value pairs that will be attached to this event 26 | /// Throws a `domainNotSet` error if it has been configured with an empty domain 27 | public func trackPageview(path: String, properties: [String: String] = [:]) throws { 28 | guard self.domain != "" else { 29 | throw PlausibleError.domainNotSet 30 | } 31 | 32 | plausibleRequest(name: "pageview", path: path, properties: properties) 33 | } 34 | 35 | /// Sends a named event to Plausible for the specified path 36 | /// - Parameters: 37 | /// - event: an arbitrary event name for your analytics. 38 | /// - path: a URL path to use as the pageview location (as if it was viewed on a website). There doesn't have to be anything served at this URL. 39 | /// - properties: (optional) a dictionary of key-value pairs that will be attached to this event 40 | /// Throws a `domainNotSet` error if it has been configured with an empty domain. 41 | /// Throws a `eventIsPageview` error if you try to specific the event name as `pageview` which may indicate that you're holding it wrong. 42 | public func trackEvent(event: String, path: String, properties: [String: String] = [:]) throws { 43 | guard event != "pageview" else { 44 | throw PlausibleError.eventIsPageview 45 | } 46 | 47 | plausibleRequest(name: event, path: path, properties: properties) 48 | } 49 | 50 | private func plausibleRequest(name: String, path: String, properties: [String: String]) { 51 | var req = URLRequest(url: PlausibleAPIEventURL) 52 | req.httpMethod = "POST" 53 | req.setValue("application/json", forHTTPHeaderField: "Content-Type") 54 | 55 | var jsonObject: [String: Any] = ["name": name,"url": constructPageviewURL(path: path),"domain": domain] 56 | if !properties.isEmpty { 57 | jsonObject["props"] = properties 58 | } 59 | 60 | let jsonData = try? JSONSerialization.data(withJSONObject: jsonObject) 61 | req.httpBody = jsonData 62 | 63 | URLSession.shared.dataTask(with: req) { data, response, err in 64 | if let err = err { 65 | var resString = "" 66 | if let data { 67 | resString = String(data: data, encoding: .utf8) ?? "" 68 | } 69 | print("error sending pageview to Plausible: \(err): \(resString)") 70 | } 71 | }.resume() 72 | } 73 | 74 | internal func constructPageviewURL(path: String) -> String { 75 | let url = URL(string: "https://\(domain)")! 76 | 77 | // TODO: replace with iOS 16-only path methods at some point 78 | return url.appendingPathComponent(path).absoluteString 79 | } 80 | } 81 | 82 | public enum PlausibleError: Error { 83 | case domainNotSet 84 | case invalidDomain 85 | case eventIsPageview 86 | } 87 | -------------------------------------------------------------------------------- /Tests/PlausibleSwiftTests/PlausibleSwiftTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import PlausibleSwift 3 | 4 | final class PlausibleSwiftTests: XCTestCase { 5 | func testInitWithDomain() { 6 | do { 7 | _ = try PlausibleSwift(domain: "5calls.org") 8 | } catch { 9 | assertionFailure("failed to create a plausible object with a domain") 10 | } 11 | } 12 | 13 | func testInvalidDomain() { 14 | do { 15 | _ = try PlausibleSwift(domain: "5calls") 16 | } catch let err as PlausibleError { 17 | assert(err == PlausibleError.invalidDomain, "domain should be invalid") 18 | } catch { 19 | assertionFailure("some other unknown error while init with a bad domain") 20 | } 21 | } 22 | 23 | func testSinglePathConstruction() { 24 | let plausible = try! PlausibleSwift(domain: "5calls.org") 25 | 26 | let urlString = plausible.constructPageviewURL(path: "/") 27 | assert(urlString == "https://5calls.org/", "pageview url was \(urlString)") 28 | } 29 | 30 | func testMultiplePathConstruction() { 31 | let plausible = try! PlausibleSwift(domain: "5calls.org") 32 | 33 | let urlString = plausible.constructPageviewURL(path: "/issue/dont-ban-tiktok") 34 | assert(urlString == "https://5calls.org/issue/dont-ban-tiktok", "pageview url was \(urlString)") 35 | } 36 | 37 | func testNoSlashPathConstruction() { 38 | let plausible = try! PlausibleSwift(domain: "5calls.org") 39 | 40 | let urlString = plausible.constructPageviewURL(path: "all") 41 | assert(urlString == "https://5calls.org/all", "pageview url was \(urlString)") 42 | } 43 | } 44 | --------------------------------------------------------------------------------