Skip to content
Open
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
37 changes: 27 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,16 @@ Because this library demands OIDC support from the Penn Labs Platform, a client
- `analyticsRoot: String`: The root keypath for analytics tokens. For example, in Penn Mobile, all analytics values will look like `pennmobile.{FEATURE}.{SUBFEATURE}`. In that case, the `analyticsRoot = "pennmobile"`.
- I decided to not make analytics optional. This is because I wanted the analytics requests to be simple with very little room for failure. If analytics were optional, I would want all analytics functions to throw errors instead of silently failing, which would lead to additional complexity when calling these functions.
- `clientId: String` and `redirectUrl: String`. These are issued by Platform.
- `configuration: LabsPlatform.Configuration`. See below.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have we ever considered just using DocC instead at this point?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did DocC commenting for a lot of stuff that's public facing, README was easier to maintain though.

- `loginHandler: (Bool) async -> Void`: This function is run on startup and when the login state changes. The boolean argument is `true` when the user is logged in, `false` otherwise.
- `defaultLoginHandler: () -> Void`: The App Store requires a default login for most apps (for App Store verification purposes). This function will run if the default login credentials are intercepted by the login WebView.


## Configuration

Many features of LabsPlatformSwift can be configured. You can see all configuration options by looking at the `LabsPlatform.Configuration` object.

Observe that **analytics can be globally disabled** by passing a `nil` `AnalyticsConfiguration` object to the `LabsPlatform.Configuration`. In this case, all analytics requests will silently fail/do nothing.

<p align="right">(<a href="#readme-top">back to top</a>)</p>

Expand Down Expand Up @@ -118,7 +123,7 @@ When the button is pressed, a WebView sheet should appear, prompting a log-in us
There are a few ways to approach network requests using this library. An important factor is the endpoint: Swift `URLRequest` often does not retain authorization headers if the network request is redirected. Hence, the package provides two ways of approaching authenticated network requests.

### Aside
For both methods, the user has the option to choose between two `PlatformAuthMode`s: `.jwt` and `.accessToken`. These are used by the various Penn Labs services. In most cases, a developer may opt to use the `.accessToken` (since this token is supported by most Penn Labs Mobile Backend services). However, there are some services (like Analytics) that require a JWT (JSON Web Token).
For both methods, the user has the option to choose between two `PlatformAuthMode`s: `.jwt` and `.accessToken`. These are used by the various Penn Labs services. In most cases, a developer may opt to use the `.accessToken` (since this token is supported by most Penn Labs Mobile Backend services, as well as analytics). However, we also provide the option to pass the ID Token JSON Web Token, if passing an ID token for some kind of validation is desired by one's backend server.

While Analytics, for example, is handled natively by this library, access to both kinds of tokens is given.

Expand All @@ -131,15 +136,15 @@ The package provides an extension to the `Foundation.URLRequest` class. You can
// replace with your URL
let url = URL(string: "https://platform.pennlabs.org/accounts/me/")!

var request: URLRequest = try await URLRequest(url: url, mode: PlatformAuthMode.jwt)
var request: URLRequest = try await URLRequest(url: url, mode: PlatformAuthMode.accessToken)
```

Note that this initializer is both asynchronous and throwing. It is asynchronous because it fetches a refreshed token prior to returning the `URLRequest` object. It is throwing because the user may not be logged in, Platform may not be enabled, or other issues may arise that prevent the creation of an authenticated `URLRequest`. Hence, another way of handling this is as follows (more code is provided, for reference, since this is the intended use)

```swift
func getMyIdentity() async -> Identity? {
let url = URL(string: "https://platform.pennlabs.org/accounts/me/")!
guard let request = try? await URLRequest(url: url, mode: PlatformAuthMode.jwt) else {
guard let request = try? await URLRequest(url: url, mode: PlatformAuthMode.accessToken) else {
return nil
}

Expand All @@ -162,7 +167,7 @@ The package provides an extension to the `Foundation.URLSession` class. You can

```swift
// This has an optional config parameter that defaults to URLSessionConfiguration.default, but can be overridden.
var session: URLSession = try await URLSession(mode: .jwt)
var session: URLSession = try await URLSession(mode: .accessToken)

```

Expand All @@ -173,7 +178,7 @@ func getMyIdentity() async -> Identity? {
// replace with your URL
let url = URL(string: "https://platform.pennlabs.org/accounts/me/")!

guard let session = try? await URLSession(mode: .jwt) else {
guard let session = try? await URLSession(mode: .accessToken) else {
return nil
}

Expand All @@ -199,10 +204,16 @@ A large motivation for the project was to implement easy-to-use analytics into o

Analytics are incredibly valuable when making design or roadmap decisions. Given enough time and data, analytics allow developer to understand points of **friction** in their applications, perform **A/B testing** (not implemented...yet?), and otherwise better understand the **user experience** in a quantifiable way.

*Side note: this library was originally designed solely for the brand new Penn Labs Analytics API, but upon realizing it requires JWT for verification, the package's objective was widened to support general authentication as well.*
*Side note: this library was originally designed solely for the brand new Penn Labs Analytics API, but upon realizing it requires authentication with Platform directly, the package's objective was widened to support general authentication as well.*

The library was designed to make logging analytics simple, especially in SwiftUI-based View Hierarchies. However, there are other ways to log analytics that can be done in non SwiftUI-based contexts.

### Configuration

The Analytics configuration is part of the overall LabsPlatform object configuration. This means you can adjust properties such as the endpoint for analytics posts, the interval after which tokens are expired (removed from memory), or the period/interval in which these values are pushed to the endpoint.

Analytics can be disabled by setting a `nil` configuration for the Analytics.Configuration object.

### SwiftUI Analytics Logging

Given an analytics key: `pennmobile.dining.kcech.breakfast.appear`, it is easy to understand the general structure. Different paths are separated by `.`, enabling an easy understanding of the exact hierarchy that led to the given key.
Expand Down Expand Up @@ -415,7 +426,7 @@ Consider the following usage, using our very own authenticated web requests.
func updateIdentity() {
Task.timedAnalyticsOperation(operation: "fetchIdentity") {
let url = URL(string: "https://platform.pennlabs.org/accounts/me/")!
guard let request = try? await URLRequest(url: url, mode: PlatformAuthMode.jwt),
guard let request = try? await URLRequest(url: url, mode: PlatformAuthMode.accessToken),
let (data, response) = try? await URLSession.data(for: request),
let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
Expand All @@ -428,15 +439,21 @@ func updateIdentity() {

By using `timedAnalyticsOperation`, the library creates an operation, runs the given task, then ends the operation and logs the total running time. This can be valuable in assessing loading times.

The consideration is that because tasks do not take place in the view hierarchy, these operations will be labeled `global.operation.{NAME}`. Duplicate operation names have not been tested.
The consideration is that because tasks do not take place in the view hierarchy, these operations will be labeled `global.operation.{NAME}`. **Duplicate operation names have not been adequately tested.**

### Analytics Wrap-Up

The analytics values we've worked hard to record are cached between app launches. This is to prevent unsent values from being lost if a user closes the app.

As for regular use cases, the analytics values are kept in a queue, which is flushed, by default, every 30 seconds. This can be modified by changing the static property: `LabsPlatform.Analytics.pushInterval`. Note that this property should be changed before `enableLabsPlatform` is run, since initializing Platform starts the DispatchQueue on a set interval (which cannot then be changed).
There are some properties in Analytics that can be configured when LabsPlatformSwift is initialized by providing an AnalyticsConfiguration object in the usual configuration.

The first property that can be modified is the push URL. This allows for testing analytics using a local server or a server other than the production server.

As for regular use cases, the analytics values are kept in a queue, which is flushed, by default, every 15 seconds. This can be modified by changing the static property: `pushInterval`. Note that this property should be changed before `enableLabsPlatform` is run, since initializing Platform starts the DispatchQueue on a set interval (which cannot then be changed).

There is also a property called `bufferTime`, which defaults to 5 seconds. This buffer will not let analytics values within that time to be recorded. This is especially important for expensive geometric appearance calculation, where a user scrolling repeatedly would trigger many analytics values to be sent.

Another property that can be changed is `LabsPlatform.Analytics.expireInterval`. By default, this value is set to `604800` seconds (7 days). On app launches, values created more than this interval ago are pruned from the queue. This is to prevent a backlog of analytics values in the event that there is a failure in some process.
Another property that can be changed is `expireInterval`. By default, this value is set to `604800` seconds (7 days). On app launches, values created more than this interval ago are pruned from the queue. This is to prevent a backlog of analytics values in the event that there is a failure in some process.

Various endpoints can also be changed throughout the library.

Expand Down
4 changes: 2 additions & 2 deletions Sources/Analytics/AnalyticsContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public struct AnalyticsContext {
guard let platform, let oper = await findOperation(key: key, operation: operation) else {
return
}
await platform.analytics.completeTimedOperation(oper)
await platform.analytics?.completeTimedOperation(oper)
}
}

Expand All @@ -44,7 +44,7 @@ public struct AnalyticsContext {
subpath.append(path[j])
}
let trialPath = subpath.joined(separator: ".")
if let oper = await platform?.analytics.getTimedOperation("\(trialPath).operation.\(operation)") {
if let oper = await platform?.analytics?.getTimedOperation("\(trialPath).operation.\(operation)") {
return oper
}
}
Expand Down
47 changes: 37 additions & 10 deletions Sources/Analytics/AnalyticsContextProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,14 @@ public struct AnalyticsContextProvider<Content: View>: View {
}

extension View {
@available(*, deprecated, message: "Switch to analytics(_:logViewAppearances:)")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we using this anywhere? I can't seem to find any uses in Penn Mobile, so if we aren't then we may as well remove this entirely

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not it just felt cool to deprecate my own code

@ViewBuilder public func analytics(_ subkey: String?, logViewAppearances: Bool) -> some View {
AnalyticsView(subkey: subkey, logViewAppearances: logViewAppearances ? .enabled : .disabled) {
self
}
}

@ViewBuilder public func analytics(_ subkey: String?, logViewAppearances: ViewAppearanceLoggingMode) -> some View {
AnalyticsView(subkey: subkey, logViewAppearances: logViewAppearances) {
self
}
Expand All @@ -53,30 +60,39 @@ extension View {
private struct AnalyticsView<Content: View>: View {
@Environment(\.labsAnalyticsPath) var path: String
let content: Content
let logViewAppearances: Bool
let logViewAppearances: ViewAppearanceLoggingMode
let subkey: String?
@State var key: String = ""
@State var onScreen: Bool = false
let platform = LabsPlatform.shared
init(subkey: String?, logViewAppearances: Bool, @ViewBuilder _ content: () -> Content) {
init(subkey: String?, logViewAppearances: ViewAppearanceLoggingMode, @ViewBuilder _ content: () -> Content) {
self.content = content()
self.subkey = subkey
self.logViewAppearances = logViewAppearances
}

var body: some View {
Group {
Comment on lines 74 to 75
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think if you annotate the body with @ViewBuilder you might not need the Group here

if logViewAppearances {
if case .enabled = logViewAppearances {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use a switch statement here instead

content
.onAppear {
guard let platform else { return }
Task {
await platform.analytics.record(AnalyticsValue(key: "\(key).appear", value: "1", timestamp: Date.now))
}
onScreen = true
}
.onDisappear {
guard let platform else { return }
Task {
await platform.analytics.record(AnalyticsValue(key: "\(key).disappear", value: "1", timestamp: Date.now))
onScreen = false
}
} else if case .enabledExpensive = logViewAppearances {
content
.background {
GeometryReader { proxy in
Color.clear
.onChange(of: proxy.frame(in: .global)) {
let frame = proxy.frame(in: .global)
let intersects = frame.intersects(.init(x: 0, y: 0, width: UIScreen.main.bounds.width, height: UIScreen.main.bounds.height))
if intersects != onScreen {
onScreen = intersects
}
}
}
}
} else {
Expand All @@ -87,5 +103,16 @@ private struct AnalyticsView<Content: View>: View {
.onAppear {
self.key = subkey == nil ? path : "\(path).\(subkey!)"
}
.onChange(of: onScreen) {
Task {
await platform?.analytics?.record(AnalyticsValue(key: "\(key).\(onScreen ? "appear" : "disappear")", value: "1", timestamp: Date.now))
}
}
}
}

public enum ViewAppearanceLoggingMode {
case disabled, enabled, enabledExpensive
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably make enabledExpensive more explicit, so maybe something like case disabled, lifecycleBased, screenPositionBased


// enabledExpensive for more accurate processing, such as for portal posts where we actually want to be sure that it was on screen, instead of just in the VStack
}
4 changes: 2 additions & 2 deletions Sources/Analytics/AnalyticsTimedOperation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ public extension Task where Success == Void, Failure == Never {
static func timedAnalyticsOperation(name: String, cancelOnScenePhase: [ScenePhase] = [.background, .inactive], _ operation: @Sendable @escaping () async -> Void) {
Task {
let analytic = AnalyticsTimedOperation(fullKey: "global.operation.\(name)", cancelOnScenePhase: cancelOnScenePhase)
await LabsPlatform.shared?.analytics.addTimedOperation(analytic)
await LabsPlatform.shared?.analytics?.addTimedOperation(analytic)
await operation()
await LabsPlatform.shared?.analytics.completeTimedOperation(analytic)
await LabsPlatform.shared?.analytics?.completeTimedOperation(analytic)
}
}
}
Expand Down
21 changes: 17 additions & 4 deletions Sources/Auth/LabsPlatformAuth.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ extension LabsPlatform {
let verifier: String = AuthUtilities.codeVerifier()
let state: String = AuthUtilities.stateString()
guard let url = URL(string:
"\(LabsPlatform.authEndpoint.absoluteString)?response_type=code&code_challenge=\(AuthUtilities.codeChallenge(from: verifier))&code_challenge_method=S256&client_id=\(self.clientId)&redirect_uri=\(self.authRedirect)&scope=openid%20read%20introspection&state=\(state)") else { throw PlatformAuthError.invalidUrl }
"\(configuration.authEndpoint.absoluteString)?response_type=code&code_challenge=\(AuthUtilities.codeChallenge(from: verifier))&code_challenge_method=S256&client_id=\(self.clientId)&redirect_uri=\(self.authRedirect)&scope=openid%20read%20introspection&state=\(state)") else { throw PlatformAuthError.invalidUrl }
return .newLogin(url: url, state: state, verifier: verifier)
}

Expand Down Expand Up @@ -241,7 +241,7 @@ extension LabsPlatform {

let postData = postString.data(using: .utf8)

var request = URLRequest(url: LabsPlatform.tokenEndpoint)
var request = URLRequest(url: configuration.tokenEndpoint)
request.addValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
request.httpMethod = "POST"
request.httpBody = postData
Expand Down Expand Up @@ -271,7 +271,7 @@ extension LabsPlatform {

let postData = postString.data(using: .utf8)

var request = URLRequest(url: LabsPlatform.tokenEndpoint)
var request = URLRequest(url: configuration.tokenEndpoint)
request.addValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
request.httpMethod = "POST"
request.httpBody = postData
Expand All @@ -291,7 +291,20 @@ extension LabsPlatform {
return .failure(DecodingError.valueNotFound(PlatformAuthCredentials.self, DecodingError.Context(codingPath: [], debugDescription: "Could not decode credentials")))
}

return .success(data)
if let idToken = data.idToken {
return .success(data)
} else {
// specifically retain the ID token from initial auth (if available)

let combinedToken = PlatformAuthCredentials(
accessToken: data.accessToken,
expiresIn: data.expiresIn,
tokenType: data.tokenType,
refreshToken: data.refreshToken,
idToken: auth.idToken,
issuedAt: data.issuedAt)
return .success(combinedToken)
}
}
}

Expand Down
Loading