Skip to content

Commit f609e2c

Browse files
authored
Add Telemetry Events (#75)
1 parent da86e72 commit f609e2c

9 files changed

+447
-43
lines changed
+71
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
//
2+
// TelemetryEvent.swift
3+
//
4+
// MIT License
5+
//
6+
// Copyright (c) 2025 The Trade Desk (https://www.thetradedesk.com/)
7+
//
8+
// Permission is hereby granted, free of charge, to any person obtaining a copy
9+
// of this software and associated documentation files (the "Software"), to deal
10+
// in the Software without restriction, including without limitation the rights
11+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
12+
// copies of the Software, and to permit persons to whom the Software is
13+
// furnished to do so, subject to the following conditions:
14+
//
15+
// The above copyright notice and this permission notice shall be included in all
16+
// copies or substantial portions of the Software.
17+
//
18+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
21+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
24+
// SOFTWARE.
25+
//
26+
27+
import Foundation
28+
29+
private let stackTraceMaxSize = 10_000
30+
31+
struct TelemetryEvent: Encodable {
32+
var clientId: String
33+
var name: String
34+
var message: String
35+
var eventType: EventType
36+
37+
enum EventType {
38+
case info
39+
case error(stackTrace: String?)
40+
}
41+
42+
enum CodingKeys: String, CodingKey {
43+
case clientId
44+
case name
45+
case message
46+
case eventType
47+
case stackTrace
48+
}
49+
50+
func encode(to encoder: any Encoder) throws {
51+
var container = encoder.container(keyedBy: CodingKeys.self)
52+
try container.encode(clientId, forKey: .clientId)
53+
try container.encode(name, forKey: .name)
54+
try container.encode(message, forKey: .message)
55+
switch eventType {
56+
case .info:
57+
try container.encode("info", forKey: .eventType)
58+
case .error(let stackTrace):
59+
try container.encode("error", forKey: .eventType)
60+
if let stackTrace {
61+
try container.encode(String(stackTrace.prefix(stackTraceMaxSize)), forKey: .stackTrace)
62+
}
63+
}
64+
}
65+
}
66+
67+
extension Thread {
68+
static var formattedCallStackSymbols: String {
69+
callStackSymbols.joined(separator: "\n")
70+
}
71+
}

Sources/OpenPass/DeviceAuthorizationFlow.swift

+51-6
Original file line numberDiff line numberDiff line change
@@ -95,12 +95,24 @@ public final class DeviceAuthorizationFlow {
9595
// Reset in case the flow is reused
9696
slowDownMultiplier = 0
9797

98-
let authorizeDeviceCodeResponse = try await openPassClient.getDeviceCode()
99-
switch authorizeDeviceCodeResponse {
100-
case .success(let response):
101-
return DeviceCode(response: response, now: dateGenerator.now)
102-
case .failure(let error):
103-
throw OpenPassError.unableToGenerateDeviceCode(name: error.error, description: error.errorDescription)
98+
do {
99+
let authorizeDeviceCodeResponse = try await openPassClient.getDeviceCode()
100+
switch authorizeDeviceCodeResponse {
101+
case .success(let response):
102+
return DeviceCode(response: response, now: dateGenerator.now)
103+
case .failure(let error):
104+
throw OpenPassError.unableToGenerateDeviceCode(name: error.error, description: error.errorDescription)
105+
}
106+
} catch {
107+
try? await openPassClient.recordEvent(
108+
.init(
109+
clientId: openPassClient.clientId,
110+
name: "device_flow_device_code_failure",
111+
message: "Failed to fetch device code",
112+
eventType: .info
113+
)
114+
)
115+
throw error
104116
}
105117
}
106118

@@ -118,6 +130,29 @@ public final class DeviceAuthorizationFlow {
118130
} catch OpenPassError.tokenAuthorizationPending {
119131
// Keep polling
120132
continue
133+
} catch {
134+
Task<Void, Never> {
135+
if case OpenPassError.tokenExpired = error {
136+
try? await openPassClient.recordEvent(
137+
.init(
138+
clientId: openPassClient.clientId,
139+
name: "device_flow_token_expired",
140+
message: "Token expired",
141+
eventType: .info
142+
)
143+
)
144+
} else {
145+
try? await openPassClient.recordEvent(
146+
.init(
147+
clientId: openPassClient.clientId,
148+
name: "device_flow_token_failure",
149+
message: "Failed to fetch tokens from device code",
150+
eventType: .error(stackTrace: Thread.formattedCallStackSymbols)
151+
)
152+
)
153+
}
154+
}
155+
throw error
121156
}
122157
}
123158
}
@@ -158,6 +193,16 @@ public final class DeviceAuthorizationFlow {
158193
// Verify ID Token
159194
guard let idToken = openPassTokens.idToken,
160195
try await verify(idToken) else {
196+
Task<Void, Never> {
197+
try? await openPassClient.recordEvent(
198+
.init(
199+
clientId: openPassClient.clientId,
200+
name: "device_flow_token_verification_failure",
201+
message: "Token verification failed",
202+
eventType: .error(stackTrace: nil)
203+
)
204+
)
205+
}
161206
throw OpenPassError.verificationFailedForOIDCToken
162207
}
163208

Sources/OpenPass/OpenPassClient.swift

+49-11
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,19 @@ internal final class OpenPassClient {
6969
: .disabled
7070
}
7171

72+
// MARK: - Telemetry
73+
74+
/// Record a telemetry event
75+
/// `/v1/api/telemetry/sdk_event`
76+
/// - Parameters:
77+
/// - event: The event to record
78+
func recordEvent(_ event: TelemetryEvent) async throws {
79+
let logTag: StaticString = "Telemetry Event"
80+
os_log(logTag, log: log, type: .debug)
81+
let request = Request.telemetryEvent(event)
82+
try await execute(request, String(logTag))
83+
}
84+
7285
// MARK: - Tokens
7386

7487
/// Network call to get an ``OpenPassTokens``
@@ -148,11 +161,16 @@ internal final class OpenPassClient {
148161

149162
var urlRequest = URLRequest(url: urlComponents.url!)
150163
urlRequest.httpMethod = request.method.rawValue
151-
if request.method == .get {
152-
urlComponents.queryItems = request.queryItems
153-
} else if request.method == .post {
164+
165+
switch request.body {
166+
case .none:
167+
break
168+
case .form(let queryItems):
154169
urlRequest.addValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
155-
urlRequest.httpBody = encodedPostBody(request.queryItems)
170+
urlRequest.httpBody = encodedPostBody(queryItems)
171+
case .json(let encodable):
172+
urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
173+
urlRequest.httpBody = try? Self.encoder().encode(encodable)
156174
}
157175

158176
baseRequestParameters.asHeaderPairs.forEach { field, value in
@@ -161,31 +179,51 @@ internal final class OpenPassClient {
161179
return urlRequest
162180
}
163181

182+
static internal func encoder() -> JSONEncoder {
183+
let encoder = JSONEncoder()
184+
encoder.keyEncodingStrategy = .convertToSnakeCase
185+
return encoder
186+
}
187+
164188
private func encodedPostBody(_ queryItems: [URLQueryItem]) -> Data {
165189
var urlComponents = URLComponents()
166190
urlComponents.queryItems = queryItems
167191
let query = urlComponents.query ?? ""
168192
return Data(query.utf8)
169193
}
170194

171-
private func execute<ResponseType: Decodable>(
172-
_ request: Request<ResponseType>,
195+
private func execute(
196+
_ request: URLRequest,
173197
_ logTag: String
174-
) async throws -> ResponseType {
175-
let urlRequest = urlRequest(
176-
request
177-
)
198+
) async throws -> Data {
178199
let data: Data
179200
let response: HTTPURLResponse
180201
do {
181-
(data, response) = try await self.data(for: urlRequest)
202+
(data, response) = try await self.data(for: request)
182203
} catch {
183204
os_log("Client request error %@", log: log, type: .error, logTag)
184205
throw error
185206
}
186207
if response.statusCode != 200 {
187208
os_log("Client request error (%d) %@", log: log, type: .error, response.statusCode, logTag)
188209
}
210+
return data
211+
}
212+
213+
private func execute(
214+
_ request: Request<Void>,
215+
_ logTag: String
216+
) async throws {
217+
let urlRequest = urlRequest(request)
218+
_ = try await execute(urlRequest, logTag)
219+
}
220+
221+
private func execute<ResponseType: Decodable>(
222+
_ request: Request<ResponseType>,
223+
_ logTag: String
224+
) async throws -> ResponseType {
225+
let urlRequest = urlRequest(request)
226+
let data = try await execute(urlRequest, logTag)
189227
let decoder = JSONDecoder()
190228
decoder.keyDecodingStrategy = .convertFromSnakeCase
191229
do {

Sources/OpenPass/RefreshTokenFlow.swift

+23-9
Original file line numberDiff line numberDiff line change
@@ -53,16 +53,30 @@ public final class RefreshTokenFlow {
5353
}
5454

5555
public func refreshTokens(_ refreshToken: String) async throws -> OpenPassTokens {
56-
let tokenResponse = try await openPassClient.refreshTokens(refreshToken)
57-
let openPassTokens = try OpenPassTokens(tokenResponse)
58-
// Validate ID Token
59-
guard let idToken = openPassTokens.idToken,
60-
try await verify(idToken) else {
61-
throw OpenPassError.verificationFailedForOIDCToken
62-
}
56+
do {
57+
let tokenResponse = try await openPassClient.refreshTokens(refreshToken)
58+
let openPassTokens = try OpenPassTokens(tokenResponse)
59+
// Validate ID Token
60+
guard let idToken = openPassTokens.idToken,
61+
try await verify(idToken) else {
62+
throw OpenPassError.verificationFailedForOIDCToken
63+
}
6364

64-
await tokensObserver(openPassTokens)
65-
return openPassTokens
65+
await tokensObserver(openPassTokens)
66+
return openPassTokens
67+
} catch {
68+
Task {
69+
try await openPassClient.recordEvent(
70+
.init(
71+
clientId: clientId,
72+
name: "refresh_flow_refresh_failure",
73+
message: "Failed to refresh tokens",
74+
eventType: .error(stackTrace: Thread.callStackSymbols.joined(separator: "\n"))
75+
)
76+
)
77+
}
78+
throw error
79+
}
6680
}
6781

6882
/// Verifies IDToken

Sources/OpenPass/Request.swift

+33-12
Original file line numberDiff line numberDiff line change
@@ -31,20 +31,41 @@ enum Method: String {
3131
case post = "POST"
3232
}
3333

34+
enum RequestBody {
35+
case none
36+
case form([URLQueryItem])
37+
case json(Encodable)
38+
}
39+
3440
struct Request<ResponseType> {
3541
var method: Method
3642
var path: String
37-
var queryItems: [URLQueryItem]
38-
var body: Data?
43+
var body: RequestBody
3944

40-
init(path: String, method: Method = .get, queryItems: [URLQueryItem] = [], body: Data? = nil) {
45+
init(
46+
path: String,
47+
method: Method = .get,
48+
body: RequestBody = .none
49+
) {
4150
self.path = path
4251
self.method = method
43-
self.queryItems = queryItems
4452
self.body = body
4553
}
4654
}
4755

56+
// MARK: - Telemetry
57+
58+
extension Request where ResponseType == Void {
59+
static func telemetryEvent(
60+
_ event: TelemetryEvent
61+
) -> Request {
62+
.init(
63+
path: "/v1/api/telemetry/sdk_event",
64+
method: .post,
65+
body: .json(event)
66+
)
67+
}
68+
}
4869
// MARK: - Tokens
4970

5071
extension Request where ResponseType == OpenPassTokensResponse {
@@ -57,13 +78,13 @@ extension Request where ResponseType == OpenPassTokensResponse {
5778
.init(
5879
path: "/v1/api/token",
5980
method: .post,
60-
queryItems: [
81+
body: .form([
6182
.init(name: "client_id", value: clientId),
6283
.init(name: "code_verifier", value: codeVerifier),
6384
.init(name: "code", value: code),
6485
.init(name: "grant_type", value: "authorization_code"),
6586
.init(name: "redirect_uri", value: redirectUri)
66-
]
87+
])
6788
)
6889
}
6990

@@ -74,11 +95,11 @@ extension Request where ResponseType == OpenPassTokensResponse {
7495
.init(
7596
path: "/v1/api/token",
7697
method: .post,
77-
queryItems: [
98+
body: .form([
7899
.init(name: "client_id", value: clientId),
79100
.init(name: "grant_type", value: "refresh_token"),
80101
.init(name: "refresh_token", value: refreshToken)
81-
]
102+
])
82103
)
83104
}
84105
}
@@ -90,10 +111,10 @@ extension Request where ResponseType == DeviceAuthorizationResponse {
90111
.init(
91112
path: "/v1/api/authorize-device",
92113
method: .post,
93-
queryItems: [
114+
body: .form([
94115
URLQueryItem(name: "client_id", value: clientId),
95116
URLQueryItem(name: "scope", value: "openid")
96-
]
117+
])
97118
)
98119
}
99120
}
@@ -106,11 +127,11 @@ extension Request where ResponseType == OpenPassTokensResponse {
106127
.init(
107128
path: "/v1/api/device-token",
108129
method: .post,
109-
queryItems: [
130+
body: .form([
110131
URLQueryItem(name: "client_id", value: clientId),
111132
URLQueryItem(name: "device_code", value: deviceCode),
112133
URLQueryItem(name: "grant_type", value: "urn:ietf:params:oauth:grant-type:device_code")
113-
]
134+
])
114135
)
115136
}
116137
}

0 commit comments

Comments
 (0)