Skip to content

Add Telemetry Events #75

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Mar 20, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions Sources/OpenPass/Data/TelemetryEvent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
//
// TelemetryEvent.swift
//
// MIT License
//
// Copyright (c) 2025 The Trade Desk (https://www.thetradedesk.com/)
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
//

import Foundation

private let stackTraceMaxSize = 10_000

struct TelemetryEvent: Encodable {
var clientId: String
var name: String
var message: String
var eventType: EventType

enum EventType {
case info
case error(stackTrace: String?)
}

enum CodingKeys: String, CodingKey {
case clientId
case name
case message
case eventType
case stackTrace
}

func encode(to encoder: any Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(clientId, forKey: .clientId)
try container.encode(name, forKey: .name)
try container.encode(message, forKey: .message)
switch eventType {
case .info:
try container.encode("info", forKey: .eventType)
case .error(let stackTrace):
try container.encode("error", forKey: .eventType)
if let stackTrace {
try container.encode(String(stackTrace.prefix(stackTraceMaxSize)), forKey: .stackTrace)
}
}
}
}

extension Thread {
static var formattedCallStackSymbols: String {
callStackSymbols.joined(separator: "\n")
}
}
57 changes: 51 additions & 6 deletions Sources/OpenPass/DeviceAuthorizationFlow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -95,12 +95,24 @@ public final class DeviceAuthorizationFlow {
// Reset in case the flow is reused
slowDownMultiplier = 0

let authorizeDeviceCodeResponse = try await openPassClient.getDeviceCode()
switch authorizeDeviceCodeResponse {
case .success(let response):
return DeviceCode(response: response, now: dateGenerator.now)
case .failure(let error):
throw OpenPassError.unableToGenerateDeviceCode(name: error.error, description: error.errorDescription)
do {
let authorizeDeviceCodeResponse = try await openPassClient.getDeviceCode()
switch authorizeDeviceCodeResponse {
case .success(let response):
return DeviceCode(response: response, now: dateGenerator.now)
case .failure(let error):
throw OpenPassError.unableToGenerateDeviceCode(name: error.error, description: error.errorDescription)
}
} catch {
try? await openPassClient.recordEvent(
.init(
clientId: openPassClient.clientId,
name: "device_flow_device_code_failure",
message: "Failed to fetch device code",
eventType: .info
)
)
throw error
}
}

Expand All @@ -118,6 +130,29 @@ public final class DeviceAuthorizationFlow {
} catch OpenPassError.tokenAuthorizationPending {
// Keep polling
continue
} catch {
Task<Void, Never> {
if case OpenPassError.tokenExpired = error {
try? await openPassClient.recordEvent(
.init(
clientId: openPassClient.clientId,
name: "device_flow_token_expired",
message: "Token expired",
eventType: .info
)
)
} else {
try? await openPassClient.recordEvent(
.init(
clientId: openPassClient.clientId,
name: "device_flow_token_failure",
message: "Failed to fetch tokens from device code",
eventType: .error(stackTrace: Thread.formattedCallStackSymbols)
)
)
}
}
throw error
}
}
}
Expand Down Expand Up @@ -158,6 +193,16 @@ public final class DeviceAuthorizationFlow {
// Verify ID Token
guard let idToken = openPassTokens.idToken,
try await verify(idToken) else {
Task<Void, Never> {
try? await openPassClient.recordEvent(
.init(
clientId: openPassClient.clientId,
name: "device_flow_token_verification_failure",
message: "Token verification failed",
eventType: .error(stackTrace: nil)
)
)
}
throw OpenPassError.verificationFailedForOIDCToken
}

Expand Down
60 changes: 49 additions & 11 deletions Sources/OpenPass/OpenPassClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,19 @@ internal final class OpenPassClient {
: .disabled
}

// MARK: - Telemetry

/// Record a telemetry event
/// `/v1/api/telemetry/sdk_event`
/// - Parameters:
/// - event: The event to record
func recordEvent(_ event: TelemetryEvent) async throws {
let logTag: StaticString = "Telemetry Event"
os_log(logTag, log: log, type: .debug)
let request = Request.telemetryEvent(event)
try await execute(request, String(logTag))
}

// MARK: - Tokens

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

var urlRequest = URLRequest(url: urlComponents.url!)
urlRequest.httpMethod = request.method.rawValue
if request.method == .get {
urlComponents.queryItems = request.queryItems
} else if request.method == .post {

switch request.body {
case .none:
break
case .form(let queryItems):
urlRequest.addValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
urlRequest.httpBody = encodedPostBody(request.queryItems)
urlRequest.httpBody = encodedPostBody(queryItems)
case .json(let encodable):
urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
urlRequest.httpBody = try? Self.encoder().encode(encodable)
}

baseRequestParameters.asHeaderPairs.forEach { field, value in
Expand All @@ -161,31 +179,51 @@ internal final class OpenPassClient {
return urlRequest
}

static internal func encoder() -> JSONEncoder {
let encoder = JSONEncoder()
encoder.keyEncodingStrategy = .convertToSnakeCase
return encoder
}

private func encodedPostBody(_ queryItems: [URLQueryItem]) -> Data {
var urlComponents = URLComponents()
urlComponents.queryItems = queryItems
let query = urlComponents.query ?? ""
return Data(query.utf8)
}

private func execute<ResponseType: Decodable>(
_ request: Request<ResponseType>,
private func execute(
_ request: URLRequest,
_ logTag: String
) async throws -> ResponseType {
let urlRequest = urlRequest(
request
)
) async throws -> Data {
let data: Data
let response: HTTPURLResponse
do {
(data, response) = try await self.data(for: urlRequest)
(data, response) = try await self.data(for: request)
} catch {
os_log("Client request error %@", log: log, type: .error, logTag)
throw error
}
if response.statusCode != 200 {
os_log("Client request error (%d) %@", log: log, type: .error, response.statusCode, logTag)
}
return data
}

private func execute(
_ request: Request<Void>,
_ logTag: String
) async throws {
let urlRequest = urlRequest(request)
_ = try await execute(urlRequest, logTag)
}

private func execute<ResponseType: Decodable>(
_ request: Request<ResponseType>,
_ logTag: String
) async throws -> ResponseType {
let urlRequest = urlRequest(request)
let data = try await execute(urlRequest, logTag)
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
do {
Expand Down
32 changes: 23 additions & 9 deletions Sources/OpenPass/RefreshTokenFlow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,16 +53,30 @@ public final class RefreshTokenFlow {
}

public func refreshTokens(_ refreshToken: String) async throws -> OpenPassTokens {
let tokenResponse = try await openPassClient.refreshTokens(refreshToken)
let openPassTokens = try OpenPassTokens(tokenResponse)
// Validate ID Token
guard let idToken = openPassTokens.idToken,
try await verify(idToken) else {
throw OpenPassError.verificationFailedForOIDCToken
}
do {
let tokenResponse = try await openPassClient.refreshTokens(refreshToken)
let openPassTokens = try OpenPassTokens(tokenResponse)
// Validate ID Token
guard let idToken = openPassTokens.idToken,
try await verify(idToken) else {
throw OpenPassError.verificationFailedForOIDCToken
}

await tokensObserver(openPassTokens)
return openPassTokens
await tokensObserver(openPassTokens)
return openPassTokens
} catch {
Task {
try await openPassClient.recordEvent(
.init(
clientId: clientId,
name: "refresh_flow_refresh_failure",
message: "Failed to refresh tokens",
eventType: .error(stackTrace: Thread.callStackSymbols.joined(separator: "\n"))
)
)
}
throw error
}
}

/// Verifies IDToken
Expand Down
45 changes: 33 additions & 12 deletions Sources/OpenPass/Request.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,20 +31,41 @@ enum Method: String {
case post = "POST"
}

enum RequestBody {
case none
case form([URLQueryItem])
case json(Encodable)
}

struct Request<ResponseType> {
var method: Method
var path: String
var queryItems: [URLQueryItem]
var body: Data?
var body: RequestBody

init(path: String, method: Method = .get, queryItems: [URLQueryItem] = [], body: Data? = nil) {
init(
path: String,
method: Method = .get,
body: RequestBody = .none
) {
self.path = path
self.method = method
self.queryItems = queryItems
self.body = body
}
}

// MARK: - Telemetry

extension Request where ResponseType == Void {
static func telemetryEvent(
_ event: TelemetryEvent
) -> Request {
.init(
path: "/v1/api/telemetry/sdk_event",
method: .post,
body: .json(event)
)
}
}
// MARK: - Tokens

extension Request where ResponseType == OpenPassTokensResponse {
Expand All @@ -57,13 +78,13 @@ extension Request where ResponseType == OpenPassTokensResponse {
.init(
path: "/v1/api/token",
method: .post,
queryItems: [
body: .form([
.init(name: "client_id", value: clientId),
.init(name: "code_verifier", value: codeVerifier),
.init(name: "code", value: code),
.init(name: "grant_type", value: "authorization_code"),
.init(name: "redirect_uri", value: redirectUri)
]
])
)
}

Expand All @@ -74,11 +95,11 @@ extension Request where ResponseType == OpenPassTokensResponse {
.init(
path: "/v1/api/token",
method: .post,
queryItems: [
body: .form([
.init(name: "client_id", value: clientId),
.init(name: "grant_type", value: "refresh_token"),
.init(name: "refresh_token", value: refreshToken)
]
])
)
}
}
Expand All @@ -90,10 +111,10 @@ extension Request where ResponseType == DeviceAuthorizationResponse {
.init(
path: "/v1/api/authorize-device",
method: .post,
queryItems: [
body: .form([
URLQueryItem(name: "client_id", value: clientId),
URLQueryItem(name: "scope", value: "openid")
]
])
)
}
}
Expand All @@ -106,11 +127,11 @@ extension Request where ResponseType == OpenPassTokensResponse {
.init(
path: "/v1/api/device-token",
method: .post,
queryItems: [
body: .form([
URLQueryItem(name: "client_id", value: clientId),
URLQueryItem(name: "device_code", value: deviceCode),
URLQueryItem(name: "grant_type", value: "urn:ietf:params:oauth:grant-type:device_code")
]
])
)
}
}
Loading