markbattistella/formlogger
FormLogger is a drop-in SwiftUI-compatible manager for logging bugs, feature requests, and feedback — with flexible support for custom UI and backends. You power the interface; FormManager handles validation, log collection, and async submission to a lightweight backend (such
Features
- Input validation: Ensures title, message, and optional contact details are properly filled and formatted.
- Customisable view model: Use
FormManagerto power your own UI with full control over behaviour. - Repository routing: Supports single, multiple, or selectively overridden repositories based on form kind.
- Log attachment: Automatically collects and submits log data alongside user input for better context.
- Async submission: Handles network requests using
async/await, with detailed progress and error state handling.
Installation
Add FormLogger to your Swift project using Swift Package Manager.
dependencies: [
.package(url: "https://github.com/markbattistella/FormLogger", from: "26.3.22")
]Requirements
- Swift 6.0+
Usage
FormManager
FormManager is a @MainActor @Observable class. Create one instance per form view and inject it as needed.
@State private var form = FormManager(config: MyFormConfig())User-editable fields
form.title // String
form.message // String
form.contactName // String
form.contactEmail // String
form.kind // FormManager.Kind — .bug, .feature, or .feedback
form.allowContact // Bool
form.shouldCollectLogs // BoolRead-only state
form.fieldErrors // [FormManager.Field: String] — populated after validation
form.progressState // FormManager.ProgressState — current submission lifecycle phase
form.isProcessing // Bool — true while any operation is in progress
form.canSubmit // Bool — true when validated and not processing
form.isFormValid // Bool — true after a successful validation pass
form.messageCharacterLimit // Int — from FormConfigurationSubmission
do {
try await form.submit()
} catch let error as FormManager.FormResponse {
print(error.errorTitle)
print(error.errorDescription ?? "")
} catch {
print(error.localizedDescription)
}submit() returns without throwing when validation fails — check fieldErrors or gate the call behind canSubmit. It throws a FormManager.FormResponse for network and server errors.
Metadata injection
Additional key-value pairs can be merged into the submission payload at runtime:
form.mergeMetadata(["appVersion": "2.1.0", "locale": Locale.current.identifier])Progress state
FormManager.ProgressState models each phase of submission and provides UI-friendly metadata:
ProgressView(value: form.progressState.progress) // 0.0 to 1.0
Text(form.progressState.displayMessage) // e.g. "Submitting…"States: .idle, .exportingLogs, .submitting, .processingResponse, .clearingForm(timeRemaining:), .completed.
Form kinds
FormManager.Kind is a CaseIterable enum with three cases:
Picker("Type", selection: $form.kind) {
ForEach(FormManager.Kind.allCases) { kind in
Label(kind.label, systemImage: kind.systemImage).tag(kind)
}
}Cases: .bug, .feature, .feedback. Each provides a localised .label and an SF Symbols .systemImage.
Validation errors
After a failed submit(), field-specific messages are available in fieldErrors:
if let error = form.fieldErrors[.title] {
Text(error).font(.caption).foregroundStyle(.red)
}FormManager.Field cases: .title, .message, .contactName, .contactEmail.
You can also clear errors manually:
form.clearError(for: .title)
form.clearAllErrors()Configuration
Conform a type to FormConfiguration to control submission behaviour:
struct MyFormConfig: FormConfiguration {
var apiURL: URL { URL(string: "https://worker.example.com/submit")! }
var repository: Repository.Resolver {
.single(Repository.GitHub(username: "markbattistella", repository: "feedback"))
}
}Optional properties have defaults:
| Property | Default | | --- | --- | | characterLimit | 500 | | shouldClearForm | true | | clearFormDelay | .seconds(10) | | customMetadata | nil | | isDryRun | false |
Repository routing
Single repository
All form kinds route to one repository.
repository: .single(
Repository.GitHub(username: "markbattistella", repository: "feedback")
)Multiple repositories
A distinct repository per form kind. Every kind must be present or submission will crash at the missing entry.
repository: .multiple([
.bug: Repository.GitHub(username: "markbattistella", repository: "bug-tracker"),
.feature: Repository.GitHub(username: "markbattistella", repository: "feature-requests"),
.feedback: Repository.GitHub(username: "markbattistella", repository: "feedback"),
])Partial override
A shared fallback repository with optional per-kind overrides.
repository: .partial(
shared: Repository.GitHub(username: "markbattistella", repository: "feedback"),
overrides: [
.bug: Repository.GitHub(username: "markbattistella", repository: "bugs-internal"),
]
)HTTP response errors
FormManager.FormResponse conforms to LocalizedError and maps server responses to user-facing messages:
| Case | Trigger | | --- | --- | | .badRequest | HTTP 400 | | .unauthorized | HTTP 401 | | .forbidden | HTTP 403 | | .serverError | HTTP 500 | | .unexpectedStatus(Int) | Any other status code | | .networkError(URLError) | Network-level failure |
Each case provides .errorTitle (short headline) and .errorDescription (detail string).
Backend
[!CAUTION] The log file sent in the multipart form is GZIP-compressed with a
.gzextension. Decompress it on the backend using a GZIP-compatible method.
FormLogger is backend-agnostic. The reference setup uses a GitHub App for API authentication and a Cloudflare Worker to receive and forward the multipart payload to GitHub as an issue.
[!TIP] The full setup is documented in a four-part series: part 1, part 2, part 3, part 4.
License
FormLogger is available under the MIT license. See the LICENCE file for more information.
Package Metadata
Repository: markbattistella/formlogger
Default branch: main
README: README.md