Skip to content

Commit

Permalink
App telemetry (#24)
Browse files Browse the repository at this point in the history
  • Loading branch information
darrarski authored Sep 10, 2023
2 parents 65a42e7 + 88709f3 commit f9606df
Show file tree
Hide file tree
Showing 16 changed files with 714 additions and 12 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,15 @@
"version" : "1.0.0"
}
},
{
"identity" : "swiftclient",
"kind" : "remoteSourceControl",
"location" : "https://github.com/TelemetryDeck/SwiftClient.git",
"state" : {
"revision" : "98500be378267abaa072ed08c2caf7da6493489e",
"version" : "1.5.0"
}
},
{
"identity" : "swiftcsv",
"kind" : "remoteSourceControl",
Expand Down
5 changes: 5 additions & 0 deletions app/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ let package = Package(
],
dependencies: [
.package(url: "https://github.com/ActuallyTaylor/SwiftHTMLToMarkdown.git", from: "1.1.0"),
.package(url: "https://github.com/TelemetryDeck/SwiftClient.git", from: "1.5.0"),
.package(url: "https://github.com/pointfreeco/swift-composable-architecture.git", from: "1.0.0"),
],
targets: [
Expand All @@ -28,6 +29,10 @@ let package = Package(
.target(name: "FeedFeature"),
.target(name: "ProjectsFeature"),
.product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
.product(name: "TelemetryClient", package: "SwiftClient"),
],
resources: [
.embedInCode("Secrets/TelemetryDeckAppID"),
]
),
.testTarget(
Expand Down
17 changes: 17 additions & 0 deletions app/Sources/AppFeature/App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,25 @@ import SwiftUI

@main
struct App: SwiftUI.App {
init() {
appTelemetry.initialize()
appTelemetry.send("App.init")
}

@Dependency(\.appTelemetry) var appTelemetry
let store = Store(initialState: AppReducer.State()) {
AppReducer()
AppTelemetryReducer()
} withDependencies: {
$0.openURL = .init { [dependencies = $0] url in
defer {
dependencies.appTelemetry.send(.init(
type: "OpenURL",
payload: ["url": url.absoluteString]
))
}
return await dependencies.openURL(url)
}
}

var body: some Scene {
Expand Down
67 changes: 67 additions & 0 deletions app/Sources/AppFeature/AppTelemetryClient.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import Dependencies
import Foundation
import TelemetryClient
import XCTestDynamicOverlay
import OSLog

struct AppTelemetryClient: Sendable {
var initialize: @Sendable () -> Void
var send: @Sendable (AppTelemetrySignal) -> Void

func send(_ signalType: String) {
send(AppTelemetrySignal(type: signalType))
}
}

extension AppTelemetryClient: TestDependencyKey {
static let testValue = AppTelemetryClient(
initialize: unimplemented("\(Self.self).initialize"),
send: unimplemented("\(Self.self).send")
)

static let previewValue = AppTelemetryClient(
initialize: { log.debug("initialize") },
send: { log.debug("send \($0.type)\($0.payload.isEmpty ? "" : " \($0.payload)")") }
)
}

extension DependencyValues {
var appTelemetry: AppTelemetryClient {
get { self[AppTelemetryClient.self] }
set { self[AppTelemetryClient.self] = newValue }
}
}

extension AppTelemetryClient: DependencyKey {
static let liveValue = AppTelemetryClient(
initialize: {
guard !TelemetryManager.isInitialized, let appID = Self.appID() else { return }
TelemetryManager.initialize(with: .init(appID: appID))
},
send: { signal in
guard TelemetryManager.isInitialized else { return }
TelemetryManager.send(
signal.type,
for: signal.clientUser,
floatValue: signal.floatValue,
with: signal.payload
)
}
)

private static let log = Logger(
subsystem: Bundle.main.bundleIdentifier!,
category: "AppTelemetryClient"
)

private static func appID() -> String? {
let data = Data(PackageResources.TelemetryDeckAppID)
let string = String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines)
guard let string, !string.isEmpty else {
log.fault("Missing TelemetryDeck AppID (app/Sources/AppFeature/Secrets/TelemetryDeckAppID)")
return nil
}
return string
}
}
51 changes: 51 additions & 0 deletions app/Sources/AppFeature/AppTelemetryPayload.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import ContactFeature
import Foundation
import Mastodon
import ProjectsFeature

protocol AppTelemetryPayloadProviding {
var appTelemetryPayload: [String: String] { get }
}

extension Contact.Link: AppTelemetryPayloadProviding {
var appTelemetryPayload: [String: String] { id.appTelemetryPayload }
}

extension Contact.Link.ID: AppTelemetryPayloadProviding {
var appTelemetryPayload: [String: String] { ["contact.link.id": rawValue] }
}

extension Project: AppTelemetryPayloadProviding {
var appTelemetryPayload: [String : String] { id.appTelemetryPayload }
}

extension Project.ID: AppTelemetryPayloadProviding {
var appTelemetryPayload: [String: String] {
["project.id": "\(DateFormatter.yearMonthDay.string(from: date)) \(name)"]
}
}

extension Mastodon.Status: AppTelemetryPayloadProviding {
var appTelemetryPayload: [String: String] { id.appTelemetryPayload }
}

extension Mastodon.Status.ID: AppTelemetryPayloadProviding {
var appTelemetryPayload: [String: String] { ["mastodon.status.id": rawValue] }
}

extension Mastodon.MediaAttachment: AppTelemetryPayloadProviding {
var appTelemetryPayload: [String: String] { id.appTelemetryPayload }
}

extension Mastodon.MediaAttachment.ID: AppTelemetryPayloadProviding {
var appTelemetryPayload: [String: String] { ["mastodon.media-attachment.id": rawValue] }
}

private extension DateFormatter {
static let yearMonthDay: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
formatter.timeZone = TimeZone(secondsFromGMT: 0)
return formatter
}()
}
126 changes: 126 additions & 0 deletions app/Sources/AppFeature/AppTelemetryReducer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import ComposableArchitecture
import Foundation
import TelemetryClient

typealias AppTelemetryReducerOf<R: Reducer> = AppTelemetryReducer<R.State, R.Action>

struct AppTelemetryReducer<State, Action>: Reducer {
@Dependency(\.appTelemetry) var appTelemetry

func reduce(into _: inout State, action: Action) -> Effect<Action> {
let action = UncheckedSendable(action)
return .run(priority: .low) { send in
appTelemetry.send(.init(
type: describe(action.wrappedValue),
payload: payload(for: action.wrappedValue)
))
}
}

private func describe(_ value: Any, abbreviated: Bool = false) -> String {
let mirror = Mirror(reflecting: value)
let prefix = (abbreviated && !(value is Error)) ? "" : typeName(type(of: value))
switch mirror.displayStyle {
case .enum:
if let child = mirror.children.first {
let childLabel = child.label ?? ""
let childOutput = describe(child.value, abbreviated: true)
.nonEmpty.map { "(\($0))" } ?? ""
return "\(prefix).\(childLabel)\(childOutput)"
} else {
return "\(prefix).\(value)"
}
case .optional:
if let child = mirror.children.first {
return ".some(\(describe(child.value, abbreviated: true)))"
} else {
return "\(prefix).none"
}
case .tuple:
return mirror.children.map { label, value in
let childLabel = toupleChildLabel(label).map { "\($0):" }
let childOutput = describe(value, abbreviated: true).nonEmpty
return [childLabel, childOutput].compactMap { $0 } .joined(separator: " ")
}
.joined(separator: ", ")
default:
return typeName(mirror.subjectType)
}
}

private func typeName(_ type: Any.Type) -> String {
var name = _typeName(type, qualified: true)
if let index = name.firstIndex(of: ".") {
name.removeSubrange(...index)
}
let sanitizedName = name.replacingOccurrences(
of: #"<.+>|\(unknown context at \$[[:xdigit:]]+\)\."#,
with: "",
options: .regularExpression
)
return sanitizedName
}

private func payload(for value: Any) -> [String: String] {
var payload: [String: String] = [:]
if let error = value as? Error {
payload["error.localizedDescription"] = error.localizedDescription
let nsError = error as NSError
payload["error.domain"] = nsError.domain
payload["error.code"] = "\(nsError.code)"
}
if let providedPayload = (value as? AppTelemetryPayloadProviding)?.appTelemetryPayload {
payload.addPayload(providedPayload)
}
let mirror = Mirror(reflecting: value)
switch mirror.displayStyle {
case .enum:
if let child = mirror.children.first {
let childPayload = self.payload(for: child.value)
payload.addPayload(childPayload)
}
case .optional:
if let child = mirror.children.first {
payload = self.payload(for: child.value)
}
case .tuple:
for (_, value) in mirror.children {
let childPayload = self.payload(for: value)
payload.addPayload(childPayload)
}
default:
break
}
return payload
}

private func toupleChildLabel(_ label: String?) -> String? {
guard let label else { return nil }
let isUnlabeled = label.matches(of: #/^\.[0-9]+$/#).first != nil
return isUnlabeled ? nil : label
}
}

private extension Collection {
var nonEmpty: Self? { isEmpty ? nil : self }
}

private extension [String: String] {
mutating func addPayload(_ payload: [String: String]) {
for (key, value) in payload {
addPayload(key, value)
}
}

mutating func addPayload(_ key: String, _ value: String) {
if self[key] == nil {
self[key] = value
} else if let match = key.matches(of: #/^(?<key>.*)_(?<number>[0-9]+)$/#).first {
let duplicateKey = match.output.key
let nextNumber = (Int(match.output.number) ?? 0) + 1
addPayload("\(duplicateKey)_\(nextNumber)", value)
} else {
addPayload("\(key)_1", value)
}
}
}
6 changes: 6 additions & 0 deletions app/Sources/AppFeature/AppTelemetrySignal.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
struct AppTelemetrySignal: Equatable, Sendable {
var type: String
var clientUser: String?
var floatValue: Double?
var payload: [String: String] = [:]
}
1 change: 1 addition & 0 deletions app/Sources/AppFeature/Secrets/TelemetryDeckAppID
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

26 changes: 23 additions & 3 deletions app/Sources/ContactFeature/Contact.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,29 +20,49 @@ public struct Contact: Equatable, Sendable, Codable {
}

extension Contact {
public struct Link: Equatable, Sendable, Codable, Identifiable {
public struct Link: Equatable, Sendable, Codable {
public init(
id: String,
title: String,
url: URL,
iconURL: URL?,
target: Target
) {
self.id = id
self.id = ID(rawValue: id)
self.title = title
self.url = url
self.iconURL = iconURL
self.target = target
}

public var id: String
public var id: ID
public var title: String
public var url: URL
public var iconURL: URL?
public var target: Target
}
}

extension Contact.Link: Identifiable {
public struct ID: Hashable, Sendable, Codable {
public init(rawValue: String) {
self.rawValue = rawValue
}

public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
self.rawValue = try container.decode(String.self)
}

public var rawValue: String

public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(self.rawValue)
}
}
}

extension Contact.Link {
public enum Target: String, Equatable, Sendable, Codable {
case system
Expand Down
2 changes: 1 addition & 1 deletion app/Sources/FeedFeature/FeedView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ public struct FeedView: View {
var placeholderView: some View {
let statuses: [Status] = .preview.shuffled()[0..<3]
.enumerated()
.map(makeUpdate { $0.element.id = "preview\($0.offset)" })
.map(makeUpdate { $0.element.id = .init(rawValue: "preview\($0.offset)") })
.map(\.element)

ForEach(statuses) { status in
Expand Down
2 changes: 1 addition & 1 deletion app/Sources/FeedFeature/StatusReducer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ public struct StatusReducer: Reducer, Sendable {

case .view(.previewCardTapped):
return .run { [state] _ in
if let url = (state.status.card?.url).flatMap(URL.init) {
if let url = (state.displayStatus.card?.url).flatMap(URL.init) {
await openURL(url)
}
}
Expand Down
Loading

0 comments on commit f9606df

Please sign in to comment.