Skip to content

Improvement/attachments and requests #147

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

Closed
wants to merge 14 commits into from
Closed
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
4 changes: 2 additions & 2 deletions Backtrace.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -3814,7 +3814,7 @@
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = JWKXD469L2;
DEVELOPMENT_TEAM = "";
ENABLE_BITCODE = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
Expand Down Expand Up @@ -3898,7 +3898,7 @@
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = JWKXD469L2;
DEVELOPMENT_TEAM = "";
ENABLE_BITCODE = NO;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
Expand Down
1 change: 0 additions & 1 deletion Examples/Example-iOS-ObjC/ViewController.m
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,4 @@ - (IBAction) crashAction: (id) sender {
array[1];
}


@end
4 changes: 2 additions & 2 deletions Podfile.lock
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
PODS:
- Backtrace (2.0.6):
- Backtrace (2.0.7):
- PLCrashReporter (= 1.11.1)
- Nimble (10.0.0)
- PLCrashReporter (1.11.1)
Expand All @@ -22,7 +22,7 @@ EXTERNAL SOURCES:
:path: "./Backtrace.podspec"

SPEC CHECKSUMS:
Backtrace: 72bea7c55570b42ab18dd7218ce41ceef3edca14
Backtrace: a686b3bfb9a7a176080e259f7f8dd7a5f521d957
Nimble: 5316ef81a170ce87baf72dd961f22f89a602ff84
PLCrashReporter: 5d2d3967afe0efad61b3048d617e2199a5d1b787
Quick: 749aa754fd1e7d984f2000fe051e18a3a9809179
Expand Down
23 changes: 20 additions & 3 deletions Sources/Features/Error/BacktraceError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,15 @@ enum NetworkError: BacktraceError {
case connectionError(Error)
}

//TODO: Create and update stream error category
enum HttpError: BacktraceError {
case malformedUrl(URL)
case fileCreationFailed(URL)
case fileWriteFailed
case attachmentError(String)
case multipartFormError(Error)
case streamReadFailed
case streamWriteFailed
case unknownError
}

Expand All @@ -42,7 +49,7 @@ enum CodingError: BacktraceError {
extension HttpError {
var backtraceStatus: BacktraceReportStatus {
switch self {
case .malformedUrl:
case .malformedUrl, .fileCreationFailed, .fileWriteFailed, .attachmentError, .multipartFormError, .streamReadFailed, .streamWriteFailed:
return .unknownError
case .unknownError:
return .serverError
Expand All @@ -62,8 +69,18 @@ extension NetworkError {
extension HttpError {
var localizedDescription: String {
switch self {
case .malformedUrl(let url): return "Provided URL cannot be parsed: \(url)."
case .unknownError: return "Unknown error occurred."
case .malformedUrl(let url):
return "Provided URL cannot be parsed: \(url)."

Choose a reason for hiding this comment

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

small nit: can we either get rid of trailing dots or put the URL in quotes?
this goes for every description that has some data displayed in the string

case .fileCreationFailed(let url):
return "File Error occurred: \(url)."
case .fileWriteFailed, .streamReadFailed, .streamWriteFailed:
return "File Write Error occurred."
case .attachmentError(let string):
return "Attachment Error occurred: \(string)."
case .multipartFormError(let error):
return "Multipart Form Error occurred: \(error.localizedDescription)."
case .unknownError:
return "Unknown error occurred."
}
}
}
Expand Down
125 changes: 98 additions & 27 deletions Sources/Features/Network/MultipartRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,46 +49,117 @@ extension MultipartRequest {
let boundary = generateBoundaryString()
multipartRequest.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")

let boundaryPrefix = "--\(boundary)\r\n"
let body = NSMutableData()
// attributes
for attribute in report.attributes {
body.appendString(boundaryPrefix)
body.appendString("Content-Disposition: form-data; name=\"\(attribute.key)\"\r\n\r\n")
body.appendString("\(attribute.value)\r\n")
// output stream
// TODO: bind input & output streams
let outputStream = OutputStream.toMemory()
outputStream.open()
defer { outputStream.close() }

let writeLock = DispatchQueue(label: "backtrace.multipartRequest.writeLock")

do {
// attributes
var attributesString = ""
for attribute in report.attributes {
attributesString += "--\(boundary)\r\n"
attributesString += "Content-Disposition: form-data; name=\"\(attribute.key)\"\r\n\r\n"
attributesString += "\(attribute.value)\r\n"
}
try writeToStream(outputStream, attributesString, writeLock: writeLock)

// report
var reportString = "--\(boundary)\r\n"
Copy link
Collaborator

Choose a reason for hiding this comment

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

It would be better if we start with report rather than with attributes - if the application stops during submission part, we can still submit a valid report.

reportString += "Content-Disposition: form-data; name=\"upload_file\"; filename=\"upload_file\"\r\n"
reportString += "Content-Type: application/octet-stream\r\n\r\n"
try writeToStream(outputStream, reportString, writeLock: writeLock)
try writeLock.sync {
let data = report.reportData
let bytesWritten = data.withUnsafeBytes {
Copy link
Collaborator

Choose a reason for hiding this comment

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

why do we need to execute this part of the code in the sync context? You won't submit two identical reports.

guard let baseAddress = $0.bindMemory(to: UInt8.self).baseAddress else {
return -1
}
return outputStream.write(baseAddress, maxLength: data.count)
}
if bytesWritten < 0 {
throw HttpError.streamWriteFailed
}
}
try writeToStream(outputStream, "\r\n", writeLock: writeLock)

// attachments
for attachmentPath in Set(report.attachmentPaths) {
guard let attachment = Attachment(filePath: attachmentPath) else {
BacktraceLogger.error("Failed to create attachment for path: \(attachmentPath)")
continue
}

try writeToStream(outputStream, "--\(boundary)\r\n", writeLock: writeLock)
try writeToStream(outputStream, "Content-Disposition: form-data; name=\"\(attachment.filename)\"; filename=\"\(attachment.filename)\"\r\n", writeLock: writeLock)
try writeToStream(outputStream, "Content-Type: \(attachment.mimeType)\r\n\r\n", writeLock: writeLock)
try writeLock.sync {
let data = attachment.data
let bytesWritten = data.withUnsafeBytes {
guard let baseAddress = $0.bindMemory(to: UInt8.self).baseAddress else {
return -1
}
return outputStream.write(baseAddress, maxLength: data.count)
}
if bytesWritten < 0 {
throw HttpError.streamWriteFailed
}
}
try writeToStream(outputStream, "\r\n", writeLock: writeLock)
}

// Final boundary
try writeToStream(outputStream, "--\(boundary)--\r\n", writeLock: writeLock)

// Data from Output Stream
guard let data = outputStream.property(forKey: .dataWrittenToMemoryStreamKey) as? Data else {
Copy link
Collaborator

Choose a reason for hiding this comment

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

do we read data from the output stream here?

throw HttpError.streamReadFailed
}
// Set Content-Length
multipartRequest.setValue("\(data.count)", forHTTPHeaderField: "Content-Length")
// Attach file stream to HTTP body
multipartRequest.httpBodyStream = InputStream(data: data)
} catch {
BacktraceLogger.error("Error during multipart form creation: \(error.localizedDescription)")
throw HttpError.multipartFormError(error)
}
// report file
body.appendString(boundaryPrefix)
body.appendString("Content-Disposition: form-data; name=\"upload_file\"; filename=\"upload_file\"\r\n")
body.appendString("Content-Type: application/octet-stream\r\n\r\n")
body.append(report.reportData)
body.appendString("\r\n")

// attachments
for attachment in Set(report.attachmentPaths).compactMap(Attachment.init(filePath:)) {
body.appendString(boundaryPrefix)
body.appendString("Content-Disposition: form-data; name=\"\(attachment.filename)\"; filename=\"\(attachment.filename)\"\r\n")
body.appendString("Content-Type: \(attachment.mimeType)\r\n\r\n")
body.append(attachment.data)
Copy link
Collaborator

Choose a reason for hiding this comment

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

can we stream data instead of reading it to memory?

Copy link
Collaborator Author

@melekr melekr Jan 10, 2025

Choose a reason for hiding this comment

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

we can use a Stream.

Benefits:

  • no longer holds attachment or report data in memory before writing since data is written directly to file handle as it's being read
  • Data is never loaded entirely into memory, great for multiple and/or large attachments

Caviats:

  • Memory and buffer management
  • Error handling (incomplete streams, stream cannot be opened
  • Small buffer chunks

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

comment is on original not the updated code

body.appendString("\r\n")
}
body.appendString("\(boundaryPrefix)--")
multipartRequest.httpBody = body as Data

multipartRequest.setValue("\(body.length)", forHTTPHeaderField: "Content-Length")

return multipartRequest
}

private static func generateBoundaryString() -> String {
return "Boundary-\(NSUUID().uuidString)"
}

private static func writeToStream(_ stream: OutputStream, _ string: String, writeLock: DispatchQueue) throws {
guard let data = string.data(using: .utf8) else {
BacktraceLogger.error("Failed to convert string to UTF-8 data: \(string)")
throw HttpError.fileWriteFailed
}
try writeLock.sync {
let bytesWritten = data.withUnsafeBytes {
guard let baseAddress = $0.bindMemory(to: UInt8.self).baseAddress else {
return -1
}
return stream.write(baseAddress, maxLength: data.count)
}
if bytesWritten < 0 {
throw HttpError.streamWriteFailed
}
}
}
}

private extension NSMutableData {

func appendString(_ string: String) {
guard let data = string.data(using: String.Encoding.utf8, allowLossyConversion: false) else { return }
guard let data = string.data(using: String.Encoding.utf8, allowLossyConversion: false) else {
BacktraceLogger.error("Failed to append string as UTF-8 data: \(string)")
return
}
append(data)
}
}
51 changes: 34 additions & 17 deletions Sources/Features/Repository/Model/Attachment.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import UniformTypeIdentifiers
#if (os(iOS) || os(tvOS))
import MobileCoreServices
#elseif os(macOS)
Expand All @@ -7,42 +8,58 @@

struct Attachment {
let data: Data
// file name with extension that represents real file name
/// File name with extension that represents the real file name
let filename: String

Check warning on line 13 in Sources/Features/Repository/Model/Attachment.swift

View workflow job for this annotation

GitHub Actions / test (mac)

Trailing Whitespace Violation: Lines should not have trailing whitespace (trailing_whitespace)

Check warning on line 13 in Sources/Features/Repository/Model/Attachment.swift

View workflow job for this annotation

GitHub Actions / test (tvos)

Trailing Whitespace Violation: Lines should not have trailing whitespace (trailing_whitespace)
/// MIME type of the file
let mimeType: String

// Make sure attachments are not bigger than 10 MB.
/// Maximum allowed size for attachments (10 MB)
private static let maximumAttachmentSize = 10 * 1024 * 1024


/// Custom prefix for generated filenames
private static let filenamePrefix = "attachment_"

Check warning on line 22 in Sources/Features/Repository/Model/Attachment.swift

View workflow job for this annotation

GitHub Actions / test (mac)

Trailing Whitespace Violation: Lines should not have trailing whitespace (trailing_whitespace)

Check warning on line 22 in Sources/Features/Repository/Model/Attachment.swift

View workflow job for this annotation

GitHub Actions / test (tvos)

Trailing Whitespace Violation: Lines should not have trailing whitespace (trailing_whitespace)
/// Initializes an Attachment from a file path
/// - Parameter filePath: Path to the file
init?(filePath: String) {
let fileURL = URL(fileURLWithPath: filePath)

// Don't allow too large attachments
// Ensure file size is within maximumAttachmentSize limits
guard let fileAttributes = try? FileManager.default.attributesOfItem(atPath: filePath),
let fileSize = fileAttributes[FileAttributeKey.size] as? UInt64,
fileSize < Attachment.maximumAttachmentSize else {
fileSize <= Attachment.maximumAttachmentSize else {
BacktraceLogger.warning("Skipping attachment because fileSize couldn't be read or is larger than 10MB: \(filePath)")
return nil
}

do {
data = try Data(contentsOf: fileURL, options: Data.ReadingOptions.mappedIfSafe)
data = try Data(contentsOf: fileURL, options: .mappedIfSafe)
} catch {
BacktraceLogger.warning("Skipping attachment: \(filePath): \(error.localizedDescription)")
return nil
}

Check warning on line 42 in Sources/Features/Repository/Model/Attachment.swift

View workflow job for this annotation

GitHub Actions / test (mac)

Trailing Whitespace Violation: Lines should not have trailing whitespace (trailing_whitespace)

Check warning on line 42 in Sources/Features/Repository/Model/Attachment.swift

View workflow job for this annotation

GitHub Actions / test (tvos)

Trailing Whitespace Violation: Lines should not have trailing whitespace (trailing_whitespace)
mimeType = Attachment.mimeTypeForPath(fileUrl: fileURL)
filename = "attachment_" + fileURL.lastPathComponent
filename = Attachment.filenamePrefix + fileURL.lastPathComponent
}


/// Determines the MIME type for a file URL
/// - Parameter fileUrl: File URL
/// - Returns: MIME type as a string, or "application/octet-stream" as fallback
static private func mimeTypeForPath(fileUrl: URL) -> String {
guard let uti = UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension,
fileUrl.pathExtension as NSString, nil)?
.takeRetainedValue(),
let mimetype = UTTypeCopyPreferredTagWithClass(uti, kUTTagClassMIMEType)?
if #available(iOS 14.0, tvOS 14.0, macOS 11.0, *) {
if let type = UTType(filenameExtension: fileUrl.pathExtension)?.preferredMIMEType {
return type
}
} else {
guard let uti = UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension,
fileUrl.pathExtension as NSString, nil)?
.takeRetainedValue(),
let mimetype = UTTypeCopyPreferredTagWithClass(uti, kUTTagClassMIMEType)?
.takeRetainedValue() as String? else { return "application/octet-stream" }
return mimetype
return mimetype
}
return "application/octet-stream"
}
}
Loading