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

Improvement/attachments and requests #147

Open
wants to merge 14 commits into
base: master
Choose a base branch
from

Conversation

melekr
Copy link
Collaborator

@melekr melekr commented Dec 18, 2024

This PR refactors the MultipartRequest and Attachments implementation to transition from in-memory multipart body construction to disk-based streaming using a temporary file and improve filenameExtension detection.

Why?

  • The previous implementation (in-memory NSMutableData) held the entire multipart body in memory, risking high memory usage and even crashes for large reports or numerous chunky attachments (which is limited by available RAM), streaming to disk allows the sdk to process larger reports and attachments reliably.
  • Content-Length is now calculated from the size of the temporary file, avoiding the need to hold the entire payload in memory
  • Modern MIME Type Detection that leverage UTType APIs for iOS 14+/tvOS/macOS 11+ with fallbacks for older platforms

Changes

  • Replace NSMutableData operation with streaming Data to a temporary file
  • Use httpBodyStream to stream file content directly into the request body
  • Use FileHandle to manage file writing
  • Calculate file size after writing to ensure accurate HTTP header
  • defer file handling
  • Migrate Attachements to modern UniformTypeIdentifiers API, keep legacy support

ref: BT-5311

- Replace NSMutableData operation with streaming Data to a temporary file
- Use httpBodyStream to stream file content directly into the request body
- Use FileHandle to manage file writing
- Calculate file size after writing to ensure accurate HTTP header
- defer file handling
- Migrate to modern UniformTypeIdentifiers API, keep legacy support
- Cleanup
@melekr melekr marked this pull request as ready for review January 6, 2025 23:44
@melekr melekr requested a review from konraddysput January 6, 2025 23:44
@melekr melekr self-assigned this Jan 6, 2025
Copy link
Collaborator

@konraddysput konraddysput left a comment

Choose a reason for hiding this comment

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

The pull request description doesn't tell me why exactly we need it. Can you please share more thoughts on this?

Package.swift Outdated
@@ -6,7 +6,7 @@ let package = Package(
name: "Backtrace",
platforms: [
.iOS(.v12),
.macOS(.v10_13),
.macOS(.v11),
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 want to bump the macos target?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

macos 11 is way past its end of life, this was a perfect opportunity to let it go.
Next bump should be :

.iOS(.v13),
.macOS(.v12),
.tvOS(.v13)

Copy link
Collaborator

Choose a reason for hiding this comment

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

should it this be a part of this pull request? Later if we will try to find where have we changed the macos version, it would be hard to understand why we did that here

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

reverted

@@ -50,8 +50,13 @@ extension BacktraceCrashReporter: CrashReporting {
func generateLiveReport(exception: NSException? = nil,
attributes: Attributes,
attachmentPaths: [String] = []) throws -> BacktraceReport {
let reportData: Data
Copy link
Collaborator

Choose a reason for hiding this comment

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

why - generateLiveReport allows to pass nullable exception - why it matters?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

for testing, will remove.

let body = NSMutableData()

// temporary file to stream data
let tempURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString)
Copy link
Collaborator

Choose a reason for hiding this comment

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

what happen to the file if the app is restarted while file is being created?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

file is created in Temp directory managed by OS, if the app is restarted there is no guarantee the file is persisted but :

  • OOM : MultipartRequest will be retried/reconstructed from the already generated and persisted live report.
  • BacktraceReport/Resource : MultipartRequest will be retried/reconstructed from repository
  • Watcher: MultipartRequest will be retried/reconstructed from repository

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

unlike NSMutableData it's even possible to recover multipart request file! (not sure if necessary but can be a good excuse to consider for // TODO: T16698 - Add retry logic)

Comment on lines 61 to 63
writeToFile(fileHandle, "--\(boundary)\r\n")
writeToFile(fileHandle, "Content-Disposition: form-data; name=\"\(attribute.key)\"\r\n\r\n")
writeToFile(fileHandle, "\(attribute.value)\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.

shouldn't we do one write operation instead of multiple? I know opening writing and closing cost resources and time

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

My intention was multiple small write operations with batched writes to reduce system overhead, i'll look into it.

Copy link
Collaborator

Choose a reason for hiding this comment

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

hmm i think it would be faster if we create a string with attributes in memory and then do one write operation. There is no chance we will oom the app via string with attributes so we should be good to go

@melekr melekr requested a review from konraddysput January 7, 2025 22:37
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

@melekr
Copy link
Collaborator Author

melekr commented Jan 14, 2025

Holding data in memory for attributes or attachments changes the purpose of writing into files.
A hybrid approach would be buffering data in chunks, using a buffered stream.

Comment on lines 71 to 74
case .fileCreationFailed(let url):
return "File Error occurred: \(url)."
case .fileWriteFailed:
return "File Write Error occurred."
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggestion:

I think file is to generic and might be weird from the developer perspective that the file means a temporary file during HTTP submission. My recommendation is to make it more explicit by saying "Temporary submission file" instead.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

yes, it looks out of place
also need to properly handle an 'attachmentError'.

do {
let fileCreated = FileManager.default.createFile(atPath: tempURL.path, contents: nil, attributes: nil)
if !fileCreated {
throw HttpError.fileCreationFailed(tempURL)
Copy link
Collaborator

Choose a reason for hiding this comment

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

it won't be captured by the L:111 - which means we will return a generic error rather this this one?

try writeToFile(fileHandle, attributesString)

// 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.

Comment on lines 94 to 96
try writeToFile(fileHandle, "--\(boundary)\r\n")
try writeToFile(fileHandle, "Content-Disposition: form-data; name=\"\(attachment.filename)\"; filename=\"\(attachment.filename)\"\r\n")
try writeToFile(fileHandle, "Content-Type: \(attachment.mimeType)\r\n\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.

I think we should do it with a single write

try writeToFile(fileHandle, "--\(boundary)\r\n")
try writeToFile(fileHandle, "Content-Disposition: form-data; name=\"\(attachment.filename)\"; filename=\"\(attachment.filename)\"\r\n")
try writeToFile(fileHandle, "Content-Type: \(attachment.mimeType)\r\n\r\n")
fileHandle.write(attachment.data)
Copy link
Collaborator

@konraddysput konraddysput Jan 16, 2025

Choose a reason for hiding this comment

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

you're making an attachment copy in the temporary file. WE cannot stream a file directly from the file available somewhere?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

possible but requires creating an InputStream for each attachments.
If we decide to keep files, i'll make the necessary improvements.

@melekr melekr requested a review from perf2711 January 16, 2025 16:22
@melekr
Copy link
Collaborator Author

melekr commented Jan 16, 2025

  • file handling, cleaning and integrity checks to be added id we decide to keep it.
  • Error granularity improvements to be added

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

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

// Attach file stream to HTTP body
multipartRequest.httpBodyStream = InputStream(url: tempURL)

Choose a reason for hiding this comment

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

Instead of creating the file and setting it as httpBodyStream, couldn't you write directly to the request?

I'm not proficient in Swift, but I found this:
https://developer.apple.com/documentation/foundation/url_loading_system/uploading_streams_of_data

You can use bound streams to achieve that, it seems.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

we can, this is the the approach I was referencing:

Holding data in memory for attributes or attachments changes the purpose of writing into files. A hybrid approach would be buffering data in chunks, using a buffered stream.

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.


// 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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants