Skip to content
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

Export session logs as HAR file. #236

Merged
merged 3 commits into from
Feb 18, 2024
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
6 changes: 5 additions & 1 deletion Pulse.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 53;
objectVersion = 54;
objects = {

/* Begin PBXBuildFile section */
Expand Down Expand Up @@ -271,6 +271,7 @@
0CFF79DD29EB7A8E00BE767B /* SessionListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CFF79DB29EB7A8B00BE767B /* SessionListView.swift */; };
0CFF79DF29EB7B4300BE767B /* SessionPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CFF79DE29EB7B4300BE767B /* SessionPickerView.swift */; };
0CFF79E229EC1FA200BE767B /* ImageProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CFF79E029EC1F7800BE767B /* ImageProcessor.swift */; };
E95D6C562B7314E6004D28E4 /* HARDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = E95D6C552B7314E6004D28E4 /* HARDocument.swift */; };
/* End PBXBuildFile section */

/* Begin PBXContainerItemProxy section */
Expand Down Expand Up @@ -727,6 +728,7 @@
0CFF79E029EC1F7800BE767B /* ImageProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageProcessor.swift; sourceTree = "<group>"; };
49E82A8526D107A00070244F /* AlamofireIntegration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlamofireIntegration.swift; sourceTree = "<group>"; };
49E82A8826D1083D0070244F /* MoyaIntegration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoyaIntegration.swift; sourceTree = "<group>"; };
E95D6C552B7314E6004D28E4 /* HARDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HARDocument.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -1186,6 +1188,7 @@
0CB17F192978ABBA004E33F4 /* ManagedObjectsCountObserver.swift */,
0C3002B22986CEF30055F6C2 /* LoggerStoreIndex.swift */,
0C1050E429E1FAC1006AFDAD /* Version.swift */,
E95D6C552B7314E6004D28E4 /* HARDocument.swift */,
);
path = Helpers;
sourceTree = "<group>";
Expand Down Expand Up @@ -2012,6 +2015,7 @@
0C7A0E00297C51CE00B4B69D /* ConsoleListOptions.swift in Sources */,
0CF0D668296F189600EED9D4 /* NetworkRequestStatusCell.swift in Sources */,
0C9751562A32CBFB00DC46FF /* RemoteLoggerSelectedDeviceView.swift in Sources */,
E95D6C562B7314E6004D28E4 /* HARDocument.swift in Sources */,
0CB63A2F2975C43D00525165 /* ConsoleSearchScope.swift in Sources */,
0CF0D640296F189600EED9D4 /* ConsoleFilterViewModel.swift in Sources */,
0CF0D65A296F189600EED9D4 /* StoreDetailsView.swift in Sources */,
Expand Down
282 changes: 282 additions & 0 deletions Sources/PulseUI/Helpers/HARDocument.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
//
// HARDocument.swift
// PulseUI
//
// Created by Jota Uribe on 6/02/24.
// Copyright © 2024 kean. All rights reserved.
//

import Foundation
import Pulse

fileprivate enum HARDateFormatter {
static var formatter: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withFullDate, .withFullTime]
return formatter
}()
}

struct HARDocument: Encodable {
private let log: Log
init(store: LoggerStore) throws {
var entries: [Entry] = []
var pages: [Page] = []
try Dictionary(grouping: store.allTasks(), by: \.url).values.forEach { networkTasks in
let pageId = "page_\(pages.count)"
pages.append(
.init(
id: pageId,
startedDateTime: HARDateFormatter.formatter.string(from: networkTasks[.zero].createdAt),
title: networkTasks[.zero].url ?? ""
)
)
entries.append(contentsOf: networkTasks.map { .init(entity: $0, pageId: pageId) })
}
try store.allMessages().forEach { message in
if let task = message.task {
entries.append(.init(entity: task, pageId: "page_\(pages.count)"))
}
}
log = .init(
version: "1.2",
creator: ["name": "Pulse HAR generation tool", "version": "0.1"],
pages: pages,
entries: entries
)
}
}

extension HARDocument {
struct Log: Encodable {
let version: String
let creator: [String: String]
var pages: [Page]
var entries: [Entry]
}

struct Page: Encodable {
let id: String
let pageTimings: PageTimings
let startedDateTime: String?
let title: String

init(
id: String,
pageTimings: PageTimings = .init(),
startedDateTime: String,
title: String
) {
self.id = id
self.pageTimings = pageTimings
self.startedDateTime = startedDateTime
self.title = title
}
}

struct Entry: Encodable {
let cache: Cache
let connection: String
let pageref: String
let request: Request
let response: Response?
let serverIPAddress: String
let startedDateTime: String
let time: Double
let timings: Timings?

init(entity: NetworkTaskEntity, pageId: String) {
cache = .init()
connection = "\(entity.orderedTransactions.first?.remotePort ?? .zero)"
pageref = pageId
request = .init(
cookies: [],
headers: entity.originalRequest?.headers.compactMap { ["name": $0.key, "value": $0.value] } ?? [],
httpVersion: "HTTP/2",
method: entity.httpMethod,
queryString: [],
url: entity.url
)

response = .init(entity)

serverIPAddress = entity.orderedTransactions.first?.remoteAddress ?? ""
startedDateTime = HARDateFormatter.formatter.string(from: entity.createdAt)
time = entity.duration * 1000
timings = .init(entity.orderedTransactions.last?.timing)
}
}

struct Timings: Encodable {
let blocked: Double
let connect: Int
let dns: Int
let receive: Double
let send: Double
let ssl: Int
let wait: Double

init?(_ timing: NetworkLogger.TransactionTimingInfo?) {
if let timing {
blocked = -1
connect = Self.millisecondsBetween(
startDate: timing.fetchStartDate,
endDate: timing.connectEndDate
)

dns = Self.millisecondsBetween(
startDate: timing.domainLookupStartDate,
endDate: timing.domainLookupEndDate
)

receive = Self.intervalBetween(
startDate: timing.responseStartDate,
endDate: timing.responseEndDate
)

send = Self.intervalBetween(
startDate: timing.requestStartDate,
endDate: timing.requestEndDate
)

ssl = Self.millisecondsBetween(
startDate: timing.secureConnectionStartDate,
endDate: timing.secureConnectionEndDate
)

wait = timing.duration ?? .zero
} else {
return nil
}
}
}

struct PageTimings: Encodable {
let onContentLoad: Int
let onLoad: Int

init(
onContentLoad: Int = -1,
onLoad: Int = -1
) {
self.onContentLoad = onContentLoad
self.onLoad = onLoad
}
}
}

extension HARDocument.Entry {
struct Request: Encodable {
var bodySize: Int = -1
let cookies: [[String: String]]
let headers: [[String: String]]
let httpVersion: String
let method: String?
let queryString: [[String: String]]
let url: String?
}

struct Response: Encodable {
let bodySize: Int
let content: Content?
let cookies: [[String: String]]
let headers: [[String: String]]
let headersSize: Int
let httpVersion: String
let redirectURL: String
let status: Int
var statusText: String

init?(_ entity: NetworkTaskEntity?) {
if let entity {
bodySize = Int(entity.responseBody?.size ?? -1)
content = .init(entity.responseBody)
cookies = []
headers = entity.response?.headers.compactMap { ["name": $0.key, "value": $0.value] } ?? []
headersSize = -1
httpVersion = "HTTP/2"
redirectURL = ""
status = Int(entity.statusCode)
statusText = ""
} else {
return nil
}
}
}

struct Content: Encodable {
let compression: Int
let encoding: String?
let mimeType: String
let size: Int
var text: String = ""

init?(_ entity: LoggerBlobHandleEntity?) {
if let entity {
compression = Int(entity.size - entity.decompressedSize)
encoding = ""
mimeType = entity.contentType?.rawValue ?? ""
size = entity.data?.count ?? .zero
if let data = entity.data {
text = String(decoding: data, as: UTF8.self)
}
} else {
return nil
}
}
}

struct Cache: Encodable {
let afterRequest: Item?
let beforeRequest: Item?

init(
afterRequest: Item? = nil,
beforeRequest: Item? = nil
) {
self.afterRequest = afterRequest
self.beforeRequest = beforeRequest
}
}
}

extension HARDocument.Entry.Cache {
struct Item: Encodable {
let eTag: String
let expires: String
let hitCount: Int
let lastAccess: String

init(
eTag: String = "",
expires: String = "",
hitCount: Int = .zero,
lastAccess: String = ""
) {
self.eTag = eTag
self.expires = expires
self.hitCount = hitCount
self.lastAccess = lastAccess
}
}
}

// MARK: - Helper Methods

extension HARDocument.Timings {
fileprivate static func millisecondsBetween(startDate: Date?, endDate: Date?) -> Int {
let timeInterval = intervalBetween(startDate: startDate, endDate: endDate)
guard timeInterval != .zero else {
// return -1 if value can not be determined as indicated on HAR document specs.
return -1
}
return Int(timeInterval * 1000)
}

fileprivate static func intervalBetween(startDate: Date?, endDate: Date?) -> TimeInterval {
guard let startDate, let endDate else {
return .zero
}
return endDate.timeIntervalSince(startDate)
}
}
10 changes: 9 additions & 1 deletion Sources/PulseUI/Helpers/ShareItems.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@ import Pulse
import CoreData

public enum ShareStoreOutput: String, RawRepresentable {
case store, package, text, html
case store, package, text, html, har

var fileExtension: String {
switch self {
case .store, .package: return "pulse"
case .text: return "txt"
case .html: return "html"
case .har: return "har"
}
}
}
Expand Down Expand Up @@ -76,6 +77,11 @@ enum ShareService {
#else
return ShareItems(["Sharing as PDF is not supported on this platform"])
#endif
case .har:
let har = TextUtilities.har(from: string)
let directory = TemporaryDirectory()
let fileURL = directory.write(data: har, extension: "har")
return ShareItems([fileURL], size: Int64(har.count), cleanup: directory.remove)
}
}

Expand Down Expand Up @@ -110,12 +116,14 @@ enum ShareOutput {
case plainText
case html
case pdf
case har

var title: String {
switch self {
case .plainText: return "Text"
case .html: return "HTML"
case .pdf: return "PDF"
case .har: return "HAR"
}
}
}
Expand Down
Loading
Loading