├── .codebeatignore ├── .codecov.yml ├── .gitignore ├── .swiftlint.yml ├── Sources └── Bugsnag │ ├── Severity.swift │ ├── BugsnagMiddleware.swift │ ├── BugsnagError.swift │ ├── ISO8601Timestamp.swift │ ├── BugsnagUsers.swift │ ├── BugsnagConfiguration.swift │ ├── BugsnagPayload.swift │ ├── Application+Bugsnag.swift │ ├── Request+Bugsnag.swift │ └── BugsnagReporter.swift ├── .github └── workflows │ ├── documentation.yml │ └── test.yml ├── Package.swift ├── Tests └── BugsnagTests │ ├── TestClient.swift │ └── BugsnagTests.swift └── README.md /.codebeatignore: -------------------------------------------------------------------------------- 1 | Public/** 2 | Resources/Assets/** 3 | -------------------------------------------------------------------------------- /.codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | range: "0...100" 3 | ignore: 4 | - "Tests" 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | Packages 2 | .build 3 | .idea 4 | .DS_Store 5 | *.xcodeproj 6 | DerivedData/ 7 | Package.resolved 8 | .swiftpm 9 | Tests/LinuxMain.swift 10 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | included: 2 | - Sources 3 | function_body_length: 4 | warning: 60 5 | variable_name: 6 | min_length: 7 | warning: 2 8 | line_length: 80 9 | disabled_rules: 10 | - opening_brace 11 | colon: 12 | flexible_right_spacing: true 13 | -------------------------------------------------------------------------------- /Sources/Bugsnag/Severity.swift: -------------------------------------------------------------------------------- 1 | /// Error severity. See `BugsnagError`. 2 | public struct BugsnagSeverity { 3 | /// Information. 4 | public static let info = Self(value: "info") 5 | 6 | /// Warning. 7 | public static let warning = Self(value: "warning") 8 | 9 | /// Error. 10 | public static let error = Self(value: "error") 11 | 12 | let value: String 13 | } 14 | -------------------------------------------------------------------------------- /Sources/Bugsnag/BugsnagMiddleware.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | /// Automatically catches and reports errors to Bugsnag. 4 | /// 5 | /// app.middleware.use(BugsnagMiddleware()) 6 | /// 7 | /// This should be placed _after_ `ErrorMiddleware`. 8 | public struct BugsnagMiddleware { 9 | public init() { } 10 | } 11 | 12 | extension BugsnagMiddleware: Middleware { 13 | /// See `Middleware`. 14 | public func respond(to request: Request, chainingTo next: Responder) -> EventLoopFuture { 15 | next.respond(to: request).flatMapErrorThrowing { error in 16 | request.bugsnag.report(error) 17 | throw error 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.github/workflows/documentation.yml: -------------------------------------------------------------------------------- 1 | name: Documentation 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v1 13 | - name: Generate Documentation 14 | uses: SwiftDocOrg/swift-doc@master 15 | with: 16 | module-name: Bugsnag 17 | inputs: "Sources" 18 | output: "Documentation" 19 | - name: Upload Documentation to Wiki 20 | uses: SwiftDocOrg/github-wiki-publish-action@v1 21 | with: 22 | path: "Documentation" 23 | env: 24 | GH_PERSONAL_ACCESS_TOKEN: ${{ secrets.WIKI_ACCESS_TOKEN }} -------------------------------------------------------------------------------- /Sources/Bugsnag/BugsnagError.swift: -------------------------------------------------------------------------------- 1 | /// Errors conforming to this protocol have more control about how (or if) they will be reported. 2 | public protocol BugsnagError: Error { 3 | /// Whether to report this error (defaults to `true`) 4 | var shouldReport: Bool { get } 5 | 6 | /// Error severity (defaults to `.error`) 7 | var severity: BugsnagSeverity { get } 8 | 9 | /// Any additional metadata (defaults to `[:]`) 10 | var metadata: [String: CustomStringConvertible] { get } 11 | } 12 | 13 | public extension BugsnagError { 14 | var shouldReport: Bool { true } 15 | var severity: BugsnagSeverity { .error } 16 | var metadata: [String: CustomStringConvertible] { [:] } 17 | } 18 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.2 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "bugsnag", 6 | platforms: [ 7 | .macOS(.v10_15) 8 | ], 9 | products: [ 10 | .library(name: "Bugsnag", targets: ["Bugsnag"]), 11 | ], 12 | dependencies: [ 13 | .package(url: "https://github.com/vapor/vapor.git", from: "4.10.0"), 14 | ], 15 | targets: [ 16 | .target(name: "Bugsnag", dependencies: [ 17 | .product(name: "Vapor", package: "vapor"), 18 | ]), 19 | .testTarget(name: "BugsnagTests", dependencies: [ 20 | .target(name: "Bugsnag"), 21 | .product(name: "XCTVapor", package: "vapor"), 22 | ]), 23 | ] 24 | ) 25 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - master 7 | jobs: 8 | linux: 9 | runs-on: ubuntu-latest 10 | container: swift:5.2-bionic 11 | steps: 12 | - name: Check out code 13 | uses: actions/checkout@v2 14 | - name: Run tests with Thread Sanitizer 15 | run: swift test --enable-test-discovery --sanitize=thread 16 | macOS: 17 | runs-on: macos-latest 18 | steps: 19 | - name: Select latest available Xcode 20 | uses: maxim-lobanov/setup-xcode@v1 21 | with: 22 | xcode-version: latest 23 | - name: Check out code 24 | uses: actions/checkout@v2 25 | - name: Run tests with Thread Sanitizer 26 | run: swift test --enable-test-discovery --sanitize=thread -------------------------------------------------------------------------------- /Sources/Bugsnag/ISO8601Timestamp.swift: -------------------------------------------------------------------------------- 1 | import class Foundation.DateFormatter 2 | import struct Foundation.Locale 3 | import struct Foundation.TimeZone 4 | import struct Foundation.Date 5 | import class NIO.ThreadSpecificVariable 6 | 7 | final class ISO8601Timestamp { 8 | private static var cache: ThreadSpecificVariable = .init() 9 | 10 | static var shared: ISO8601Timestamp { 11 | let formatter: ISO8601Timestamp 12 | if let existing = self.cache.currentValue { 13 | formatter = existing 14 | } else { 15 | let new = ISO8601Timestamp() 16 | self.cache.currentValue = new 17 | formatter = new 18 | } 19 | return formatter 20 | } 21 | 22 | let formatter: DateFormatter 23 | 24 | init() { 25 | let formatter = DateFormatter() 26 | formatter.locale = Locale(identifier: "en_US_POSIX") 27 | formatter.timeZone = TimeZone(secondsFromGMT: 0) 28 | formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX" 29 | self.formatter = formatter 30 | } 31 | 32 | func current() -> String { 33 | self.formatter.string(from: Date()) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Tests/BugsnagTests/TestClient.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | extension Application.Clients { 4 | private struct TestClientKey: StorageKey { 5 | typealias Value = TestClient 6 | } 7 | 8 | var test: TestClient { 9 | if let existing = self.application.storage[TestClientKey.self] { 10 | return existing 11 | } else { 12 | let new = TestClient() 13 | self.application.storage[TestClientKey.self] = new 14 | return new 15 | } 16 | } 17 | } 18 | extension Application.Clients.Provider { 19 | static var test: Self { 20 | .init { $0.clients.use { $0.clients.test } } 21 | } 22 | } 23 | 24 | final class TestClient: Client { 25 | var requests: [ClientRequest] 26 | 27 | var eventLoop: EventLoop { 28 | EmbeddedEventLoop() 29 | } 30 | 31 | init() { 32 | self.requests = [] 33 | } 34 | 35 | func delegating(to eventLoop: EventLoop) -> Client { 36 | self 37 | } 38 | 39 | func send(_ request: ClientRequest) -> EventLoopFuture { 40 | self.requests.append(request) 41 | return self.eventLoop.makeSucceededFuture(ClientResponse()) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Sources/Bugsnag/BugsnagUsers.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | /// Configures which users will be reported by Bugsnag. 4 | /// 5 | /// // Adds TestUser to Bugsnag reports. 6 | /// app.bugsnag.users.add(TestUser.self) 7 | /// 8 | /// User types must conform to `Authenticatable` and `BugsnagUser`. 9 | /// Configured user types will be automatically included in Bugsnag reports 10 | /// if they are logged in via the authentication API when reporting though `Request`. 11 | /// 12 | /// // Logs in a user. 13 | /// req.auth.login(TestUser()) 14 | /// 15 | /// // This error report will include the logged in 16 | /// // user's identifier. 17 | /// req.bugsnag.report(someError) 18 | /// 19 | /// Only one user can be included in a Bugsnag report. 20 | public struct BugsnagUsers { 21 | var storage: [(Request) -> (CustomStringConvertible?)] 22 | 23 | public mutating func add(_ user: User.Type) 24 | where User: BugsnagUser & Authenticatable 25 | { 26 | self.storage.append({ request in 27 | request.auth.get(User.self)?.bugsnagID 28 | }) 29 | } 30 | } 31 | 32 | public protocol BugsnagUser { 33 | var bugsnagID: CustomStringConvertible? { get } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/Bugsnag/BugsnagConfiguration.swift: -------------------------------------------------------------------------------- 1 | /// Bugsnag's configuration options. 2 | /// 3 | /// app.bugsnag.configuration = .init(...) 4 | /// 5 | public struct BugsnagConfiguration { 6 | /// Notifier API key found in Bugsnag project settings. 7 | public var apiKey: String 8 | 9 | /// Which version of your app is running, like `development` or `production`. 10 | public var releaseStage: String 11 | 12 | /// A version identifier, (eg. a git hash) 13 | public var version: String? 14 | 15 | /// Defines sensitive keys that should be hidden from data reported to Bugsnag. 16 | public var keyFilters: Set 17 | 18 | /// Controls whether reports are sent to Bugsnag. 19 | public var shouldReport: Bool 20 | 21 | /// Creates a new `BugsnagConfiguration`. 22 | public init( 23 | apiKey: String, 24 | releaseStage: String, 25 | version: String? = nil, 26 | keyFilters: [String] = [], 27 | shouldReport: Bool = true 28 | ) { 29 | self.apiKey = apiKey 30 | self.releaseStage = releaseStage 31 | self.version = version 32 | self.keyFilters = Set(keyFilters) 33 | self.shouldReport = shouldReport 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/Bugsnag/BugsnagPayload.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | struct BugsnagPayload: Codable { 4 | let apiKey: String 5 | let events: [Event] 6 | 7 | struct Event: Codable { 8 | let app: Application 9 | 10 | struct Application: Codable { 11 | let releaseStage: String 12 | let version: String? 13 | } 14 | 15 | let breadcrumbs: [Breadcrumb] 16 | 17 | struct Breadcrumb: Codable { 18 | let metaData: [String: String] 19 | let name: String 20 | let timestamp: String 21 | let type: String 22 | } 23 | 24 | let exceptions: [Exception] 25 | 26 | struct Exception: Codable { 27 | let errorClass: String 28 | let message: String 29 | let stacktrace: [StackTrace] 30 | 31 | struct StackTrace: Codable { 32 | let file: String 33 | let method: String 34 | let lineNumber: Int 35 | let columnNumber: Int 36 | 37 | let code: [String] = [] 38 | let inProject = true 39 | } 40 | 41 | let type: String 42 | } 43 | 44 | let metaData: [String: String] 45 | 46 | let payloadVersion: String 47 | let request: Request? 48 | 49 | struct Request: Codable { 50 | let body: String? 51 | let clientIp: String? 52 | let headers: [String: String] 53 | let httpMethod: String 54 | let referer: String 55 | let url: String 56 | } 57 | 58 | 59 | let severity: String 60 | let unhandled = true 61 | let user: User? 62 | 63 | struct User: Codable { 64 | let id: String 65 | } 66 | } 67 | 68 | let notifier: Notifier 69 | 70 | struct Notifier: Codable { 71 | let name: String 72 | let url: String 73 | let version: String 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Sources/Bugsnag/Application+Bugsnag.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | extension Application { 4 | /// Bugsnag helper. Use to configure and send reports. 5 | /// 6 | /// // Configure Bugsnag. 7 | /// app.bugsnag.configuration = ... 8 | /// 9 | /// // Report an error. 10 | /// app.bugsnag.report(...) 11 | /// 12 | public var bugsnag: Bugsnag { 13 | .init(application: self) 14 | } 15 | 16 | /// Application's Bugsnag helper. 17 | public struct Bugsnag { 18 | /// The root application. 19 | public let application: Application 20 | } 21 | } 22 | 23 | extension Application.Bugsnag { 24 | private struct BugsnagConfigurationKey: StorageKey { 25 | typealias Value = BugsnagConfiguration 26 | } 27 | 28 | /// Configures Bugsnag for this application. 29 | /// 30 | /// This is usually set in `configure.swift`. 31 | /// 32 | /// // Configure Bugsnag. 33 | /// app.bugsnag.configuration = ... 34 | /// 35 | /// Must be set before accessing Bugsnag helpers on `Application` and `Request`. 36 | public var configuration: BugsnagConfiguration? { 37 | get { 38 | self.application.storage[BugsnagConfigurationKey.self] 39 | } 40 | nonmutating set { 41 | self.application.storage[BugsnagConfigurationKey.self] = newValue 42 | } 43 | } 44 | } 45 | 46 | extension Application.Bugsnag { 47 | private struct BugsnagUsersKey: StorageKey { 48 | typealias Value = BugsnagUsers 49 | } 50 | 51 | /// Configures which users will be reported by Bugsnag. 52 | /// 53 | /// // Adds TestUser to Bugsnag reports. 54 | /// app.bugsnag.users.add(TestUser.self) 55 | /// 56 | /// User types must conform to `Authenticatable` and `BugsnagUser`. 57 | /// Configured user types will be automatically included in Bugsnag reports 58 | /// if they are logged in via the authentication API when reporting though `Request`. 59 | /// 60 | /// // Logs in a user. 61 | /// req.auth.login(TestUser()) 62 | /// 63 | /// // This error report will include the logged in 64 | /// // user's identifier. 65 | /// req.bugsnag.report(someError) 66 | /// 67 | /// Only one user can be included in a Bugsnag report. 68 | public var users: BugsnagUsers { 69 | get { 70 | self.application.storage[BugsnagUsersKey.self] ?? .init(storage: []) 71 | } 72 | nonmutating set { 73 | self.application.storage[BugsnagUsersKey.self] = newValue 74 | } 75 | } 76 | } 77 | 78 | extension Application.Bugsnag: BugsnagReporter { 79 | /// See `BugsnagReporter`. 80 | public var logger: Logger { 81 | self.application.logger 82 | } 83 | 84 | /// See `BugsnagReporter`. 85 | public var currentRequest: Request? { 86 | nil 87 | } 88 | 89 | /// See `BugsnagReporter`. 90 | public var client: Client { 91 | self.application.client 92 | } 93 | 94 | /// See `BugsnagReporter`. 95 | public var eventLoop: EventLoop { 96 | self.application.eventLoopGroup.next() 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Sources/Bugsnag/Request+Bugsnag.swift: -------------------------------------------------------------------------------- 1 | import Vapor 2 | 3 | extension Request { 4 | /// Bugsnag helper. Used to send reports during route handling. 5 | /// 6 | /// // Report an error. 7 | /// req.bugsnag.report(someError) 8 | /// 9 | public var bugsnag: Bugsnag { 10 | .init(request: self) 11 | } 12 | 13 | /// Bugsnag helper. Used to send reports during route handling. 14 | /// 15 | /// // Report an error. 16 | /// req.bugsnag.report(someError) 17 | /// 18 | public struct Bugsnag { 19 | public let request: Request 20 | } 21 | } 22 | 23 | extension Request.Bugsnag { 24 | /// Adds a breadcrumb to all reports sent. 25 | /// 26 | /// req.bugsnag.breadcrumb("login", type: .user) 27 | /// 28 | /// - parameters: 29 | /// - name: Unique identifier for this breadcrumb. 30 | /// - type: Type of breadcrumb. 31 | /// - metadata: Optional context dictionary. 32 | @discardableResult 33 | public func breadcrumb( 34 | name: String, 35 | type: BugsnagBreadcrumbType, 36 | metadata: [String: CustomDebugStringConvertible] = [:] 37 | ) -> Request.Bugsnag { 38 | var meta: [String: String] = [:] 39 | meta.reserveCapacity(metadata.count) 40 | 41 | for (key, value) in metadata { 42 | meta[key] = value.debugDescription 43 | } 44 | 45 | let breadcrumb = BugsnagPayload.Event.Breadcrumb( 46 | metaData: meta, 47 | name: name, 48 | timestamp: ISO8601Timestamp.shared.current(), 49 | type: type.rawValue 50 | ) 51 | 52 | self.breadcrumbs.append(breadcrumb) 53 | return self 54 | } 55 | 56 | private struct BreadcrumbsKey: StorageKey { 57 | typealias Value = [BugsnagPayload.Event.Breadcrumb] 58 | } 59 | 60 | var breadcrumbs: [BugsnagPayload.Event.Breadcrumb] { 61 | get { 62 | self.request.storage[BreadcrumbsKey.self] ?? .init() 63 | } 64 | nonmutating set { 65 | self.request.storage[BreadcrumbsKey.self] = newValue 66 | } 67 | } 68 | } 69 | 70 | extension Request.Bugsnag: BugsnagReporter { 71 | /// See `BugsnagReporter`. 72 | public var currentRequest: Request? { 73 | self.request 74 | } 75 | 76 | /// See `BugsnagReporter`. 77 | public var client: Client { 78 | self.request.client 79 | } 80 | 81 | /// See `BugsnagReporter`. 82 | public var logger: Logger { 83 | self.request.logger 84 | } 85 | 86 | /// See `BugsnagReporter`. 87 | public var eventLoop: EventLoop { 88 | self.request.eventLoop 89 | } 90 | 91 | /// See `BugsnagReporter`. 92 | public var configuration: BugsnagConfiguration? { 93 | self.request.application.bugsnag.configuration 94 | } 95 | 96 | /// See `BugsnagReporter`. 97 | public var users: BugsnagUsers { 98 | self.request.application.bugsnag.users 99 | } 100 | } 101 | 102 | /// Types of Bugsnag report breadcrumbs. 103 | public enum BugsnagBreadcrumbType: String { 104 | case error 105 | case log 106 | case manual 107 | case navigation 108 | case process 109 | case request 110 | case state 111 | case user 112 | } 113 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bugsnag 🐛 2 | [![Swift Version](https://img.shields.io/badge/Swift-5.2-brightgreen.svg)](http://swift.org) 3 | [![Vapor Version](https://img.shields.io/badge/Vapor-4-e040fb.svg)](https://vapor.codes) 4 | [![Circle CI](https://circleci.com/gh/nodes-vapor/bugsnag/tree/master.svg?style=shield)](https://circleci.com/gh/nodes-vapor/bugsnag) 5 | [![codebeat badge](https://codebeat.co/badges/e93cc2d5-7365-4916-bc92-3f6bb39b18f4)](https://codebeat.co/projects/github-com-nodes-vapor-bugsnag-master) 6 | [![codecov](https://codecov.io/gh/nodes-vapor/bugsnag/branch/master/graph/badge.svg)](https://codecov.io/gh/nodes-vapor/bugsnag) 7 | [![Readme Score](http://readme-score-api.herokuapp.com/score.svg?url=https://github.com/nodes-vapor/bugsnag)](http://clayallsopp.github.io/readme-score?url=https://github.com/nodes-vapor/bugsnag) 8 | [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://raw.githubusercontent.com/nodes-vapor/bugsnag/master/LICENSE) 9 | 10 | Reporting errors to [Bugsnag](https://www.bugsnag.com/). 11 | 12 | ## 📦 Installation 13 | 14 | ### Integrating Bugsnag in your project 15 | 16 | Update your `Package.swift` file. 17 | 18 | ```swift 19 | .package(url: "https://github.com/nodes-vapor/bugsnag.git", from: "4.0.0") 20 | ``` 21 | 22 | Update `configure.swift` 23 | 24 | ```swift 25 | public func configure(_ app: Application) throws { 26 | // Configure Bugsnag. 27 | app.bugsnag.configuration = .init( 28 | apiKey: "", 29 | releaseStage: app.environment.name, 30 | shouldReport: app.environment.name != "local" 31 | ) 32 | 33 | // Add Bugsnag middleware. 34 | app.middleware.use(BugsnagMiddleware()) 35 | } 36 | ``` 37 | 38 | ### Reporting 39 | 40 | `BugsnagMiddleware` will automatically report errors thrown by your route handlers. You can report errors manually from `Application` or `Request`. 41 | 42 | ```swift 43 | // Reporting from Application. 44 | app.bugsnag.report(Abort(.internalServerError)) 45 | 46 | // Reporting from Request. 47 | app.get("test") { req in 48 | req.bugsnag.report(Abort(.upgradeRequired)) 49 | return HTTPStatus.ok 50 | } 51 | ``` 52 | 53 | By conforming to the `BugsnagError` protocol you can have full control over how your errors are reported. It has the following properties: 54 | 55 | | Name | Type | Function | Default | 56 | |---|---|---|---| 57 | | `shouldReport` | `Bool` | Opt out of error reporting by returning `false` | `true` | 58 | | `severity` | `Severity` | Indicate error severity (`.info`\|`.warning`\|`.error`) | `.error` | 59 | | `metadata` | `[String: CustomDebugStringConvertible]` | Additional metadata to include in the report | `[:]` | 60 | 61 | ### Users 62 | Conforming your `Authenticatable` model to `BugsnagUser` allows you to easily pair the data to a report. 63 | 64 | ```swift 65 | extension TestUser: BugsnagUser { 66 | var bugsnagID: CustomStringConvertible? { 67 | self.id 68 | } 69 | } 70 | ``` 71 | 72 | Configure all user models you would like Bugsnag to report. 73 | 74 | ```swift 75 | // Add to configure.swift. 76 | app.bugsnag.users.add(TestUser.self) 77 | ``` 78 | 79 | Bugsnag will automatically check Vapor's authentication API for the configured user types and report the user's identifier if they are logged in. 80 | 81 | ### Breadcrumbs 82 | Breadcrumbs enable you to attach custom events to your reports. Leave a breadcrumb using the convenience function on `Request`. 83 | 84 | ```swift 85 | req.bugsnag.breadcrumb( 86 | name: "Something happened!", 87 | type: .manual, 88 | metadata: ["foo": "bar"] 89 | ) 90 | ``` 91 | 92 | The breadcrumb types are provided by Bugsnag: 93 | 94 | ```swift 95 | enum BugsnagBreadcrumbType { 96 | case error 97 | case log 98 | case manual 99 | case navigation 100 | case process 101 | case request 102 | case state 103 | case user 104 | } 105 | ``` 106 | 107 | ### Key Filters 108 | 109 | Usually you will receive information such as headers, query params or post body fields in the reports from Bugsnag. To ensure that you do not track sensitive information, you can configure Bugsnag with a list of fields that should be filtered out: 110 | 111 | ```swift 112 | app.bugsnag.configuration = .init( 113 | apiKey: "foo", 114 | releaseStage: "debug", 115 | keyFilters: ["email", "password"] 116 | ) 117 | ``` 118 | In this case Bugsnag Reports will hide header fields, query params or post body json fields with the keys/names **email** and **password**. 119 | 120 | ⚠️ Note: If key filters are defined and Bugsnag does not know how to parse the request body, the entire body will be hidden. 121 | 122 | ## 🏆 Credits 123 | 124 | This package is developed and maintained by the Vapor team at [Nodes](https://www.nodesagency.com). 125 | 126 | ## 📄 License 127 | 128 | This package is open-sourced software licensed under the [MIT license](http://opensource.org/licenses/MIT). 129 | -------------------------------------------------------------------------------- /Tests/BugsnagTests/BugsnagTests.swift: -------------------------------------------------------------------------------- 1 | @testable import Bugsnag 2 | import XCTVapor 3 | 4 | final class BugsnagTests: XCTestCase { 5 | func testMiddleware() throws { 6 | let app = Application(.testing) 7 | defer { app.shutdown() } 8 | 9 | app.bugsnag.configuration = .init( 10 | apiKey: "foo", 11 | releaseStage: "debug" 12 | ) 13 | app.clients.use(.test) 14 | app.middleware.use(BugsnagMiddleware()) 15 | 16 | app.get("error") { req -> String in 17 | throw Abort(.internalServerError, reason: "Oops") 18 | } 19 | 20 | try app.test(.GET, "error") { res in 21 | XCTAssertEqual(res.status, .internalServerError) 22 | } 23 | 24 | XCTAssertEqual(app.clients.test.requests[0].headers.first(name: "Bugsnag-Api-Key"), "foo") 25 | let payload = try app.clients.test.requests[0].content.decode(BugsnagPayload.self) 26 | XCTAssertEqual(payload.events[0].exceptions[0].message, "Oops") 27 | } 28 | 29 | func testBreadcrumbs() throws { 30 | let app = Application(.testing) 31 | defer { app.shutdown() } 32 | 33 | app.bugsnag.configuration = .init( 34 | apiKey: "foo", 35 | releaseStage: "debug" 36 | ) 37 | app.clients.use(.test) 38 | app.get("error") { req -> HTTPStatus in 39 | req.bugsnag.breadcrumb(name: "bar", type: .state) 40 | req.bugsnag.report(Abort(.internalServerError, reason: "Oops")) 41 | return .ok 42 | } 43 | 44 | try app.test(.GET, "error") { res in 45 | XCTAssertEqual(res.status, .ok) 46 | } 47 | 48 | let payload = try app.clients.test.requests[0].content.decode(BugsnagPayload.self) 49 | XCTAssertEqual(payload.events[0].exceptions[0].message, "Oops") 50 | XCTAssertEqual(payload.events[0].breadcrumbs[0].name, "bar") 51 | } 52 | 53 | func testBlockedKeys() throws { 54 | let app = Application(.testing) 55 | defer { app.shutdown() } 56 | 57 | app.bugsnag.configuration = .init( 58 | apiKey: "foo", 59 | releaseStage: "debug", 60 | keyFilters: ["email", "password", "Authorization"] 61 | ) 62 | app.clients.use(.test) 63 | 64 | final class User: Content { 65 | var name: String 66 | var email: String 67 | var password: String 68 | var user: User? 69 | 70 | init(name: String, email: String, password: String, user: User? = nil) { 71 | self.name = name 72 | self.email = email 73 | self.password = password 74 | self.user = user 75 | } 76 | } 77 | // Test reporting error with body. 78 | do { 79 | let vapor = User( 80 | name: "Vapor", 81 | email: "hello@vapor.codes", 82 | password: "swift-rulez-123", 83 | user: .init( 84 | name: "Swift", 85 | email: "hello@swift.org", 86 | password: "super_secret" 87 | ) 88 | ) 89 | let request = Request( 90 | application: app, 91 | method: .POST, 92 | url: "/test", 93 | headers: [ 94 | "Authorization": "Bearer SupErSecretT0ken!" 95 | ], on: app.eventLoopGroup.next() 96 | ) 97 | try request.content.encode(vapor) 98 | try request.bugsnag.report(Abort(.internalServerError, reason: "Oops")).wait() 99 | } 100 | 101 | // Check error has keys filtered out. 102 | do { 103 | let payload = try app.clients.test.requests[0].content.decode(BugsnagPayload.self) 104 | let user = try JSONDecoder().decode( 105 | User.self, 106 | from: Data(payload.events[0].request!.body!.utf8) 107 | ) 108 | let headers = payload.events[0].request!.headers 109 | XCTAssertEqual(user.name, "Vapor") 110 | XCTAssertEqual(user.email, "") 111 | XCTAssertEqual(user.password, "") 112 | XCTAssertEqual(user.user?.name, "Swift") 113 | XCTAssertEqual(user.user?.email, "") 114 | XCTAssertEqual(user.user?.password, "") 115 | XCTAssertNil(user.user?.user) 116 | XCTAssertEqual(headers["Authorization"], "") 117 | } 118 | } 119 | 120 | func testUsers() throws { 121 | let app = Application(.testing) 122 | defer { app.shutdown() } 123 | 124 | app.bugsnag.configuration = .init( 125 | apiKey: "foo", 126 | releaseStage: "debug" 127 | ) 128 | app.bugsnag.users.add(TestUser.self) 129 | app.clients.use(.test) 130 | app.get("error") { req -> HTTPStatus in 131 | req.auth.login(TestUser(id: 123, name: "Vapor")) 132 | req.bugsnag.report(Abort(.internalServerError, reason: "Oops")) 133 | return .ok 134 | } 135 | 136 | try app.test(.GET, "error") { res in 137 | XCTAssertEqual(res.status, .ok) 138 | } 139 | 140 | let payload = try app.clients.test.requests[0].content.decode(BugsnagPayload.self) 141 | XCTAssertEqual(payload.events[0].user?.id, "123") 142 | } 143 | 144 | func testBugsnagError() throws { 145 | let app = Application(.testing) 146 | defer { app.shutdown() } 147 | 148 | app.bugsnag.configuration = .init( 149 | apiKey: "foo", 150 | releaseStage: "debug" 151 | ) 152 | app.bugsnag.users.add(TestUser.self) 153 | app.clients.use(.test) 154 | app.get("error") { req -> HTTPStatus in 155 | req.bugsnag.report(TestError()) 156 | return .ok 157 | } 158 | 159 | try app.test(.GET, "error") { res in 160 | XCTAssertEqual(res.status, .ok) 161 | } 162 | 163 | let payload = try app.clients.test.requests[0].content.decode(BugsnagPayload.self) 164 | XCTAssertEqual(payload.events[0].metaData["foo"], "bar") 165 | } 166 | } 167 | 168 | struct TestError: BugsnagError { 169 | var metadata: [String : CustomStringConvertible] { 170 | ["foo": "bar"] 171 | } 172 | } 173 | 174 | struct TestUser: Authenticatable, BugsnagUser { 175 | let id: Int? 176 | let name: String 177 | 178 | var bugsnagID: CustomStringConvertible? { 179 | self.id 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /Sources/Bugsnag/BugsnagReporter.swift: -------------------------------------------------------------------------------- 1 | import class Foundation.JSONSerialization 2 | import Vapor 3 | 4 | /// Capable of reporting Bugsnag errors. 5 | /// 6 | /// See `req.bugsnag` and `app.bugsnag`. 7 | public protocol BugsnagReporter { 8 | /// HTTP client used to contact Bugsnag. 9 | var client: Client { get } 10 | 11 | /// Logger to use for reporting information or errors. 12 | var logger: Logger { get } 13 | 14 | /// EventLoop to use for future returns. 15 | var eventLoop: EventLoop { get } 16 | 17 | /// Bugsnag configuration options for reporting. 18 | var configuration: BugsnagConfiguration? { get } 19 | 20 | /// Used to include additional information about the current 21 | /// request in the report. 22 | var currentRequest: Request? { get } 23 | 24 | /// Configures which users will be reported by Bugsnag. 25 | var users: BugsnagUsers { get } 26 | } 27 | 28 | extension BugsnagReporter { 29 | /// Reports an error to Bugsnag. 30 | /// 31 | /// req.bugsnag.report(someError) 32 | /// 33 | /// Conformance to `DebuggableError` and `BugsnagError` will be checked 34 | /// for additional error context. 35 | /// 36 | /// - parameters: 37 | /// - error: The error to report. 38 | @discardableResult 39 | public func report( 40 | _ error: Error 41 | ) -> EventLoopFuture { 42 | guard let configuration = self.configuration else { 43 | fatalError("Bugsnag not configured, set `app.bugsnag.configuration`.") 44 | } 45 | 46 | guard let payload = self.buildPayload( 47 | configuration: configuration, 48 | error: error 49 | ) else { 50 | return eventLoop.future(()) 51 | } 52 | 53 | let headers: HTTPHeaders = [ 54 | "Bugsnag-Api-Key": configuration.apiKey, 55 | "Bugsnag-Payload-Version": "4" 56 | ] 57 | 58 | return self.client.post("https://notify.bugsnag.com", headers: headers, beforeSend: { req in 59 | try req.content.encode(payload, as: .json) 60 | }).flatMapError { error -> EventLoopFuture in 61 | self.logger.report(error: error) 62 | return self.eventLoop.future(error: error) 63 | }.transform(to: ()) 64 | } 65 | 66 | private func buildPayload( 67 | configuration: BugsnagConfiguration, 68 | error: Error 69 | ) -> BugsnagPayload? { 70 | guard configuration.shouldReport else { 71 | return nil 72 | } 73 | if let bugsnag = error as? BugsnagError, !bugsnag.shouldReport { 74 | return nil 75 | } 76 | 77 | let breadcrumbs: [BugsnagPayload.Event.Breadcrumb] 78 | let eventRequest: BugsnagPayload.Event.Request? 79 | if let request = self.currentRequest { 80 | breadcrumbs = request.bugsnag.breadcrumbs 81 | let eventRequestBody: String? 82 | if let body = request.body.data { 83 | if !configuration.keyFilters.isEmpty { 84 | let contentType = request.headers.contentType ?? .plainText 85 | if let clean = self.cleaned( 86 | body: body, 87 | as: contentType, 88 | keyFilters: configuration.keyFilters 89 | ) { 90 | eventRequestBody = clean 91 | } else { 92 | request.logger.warning("[Bugsnag] Could not clean request body of type \(contentType).") 93 | request.logger.debug("[Bugsnag] Request bodies that cannot be cleaned will be hidden.") 94 | eventRequestBody = "" 95 | } 96 | } else { 97 | eventRequestBody = String( 98 | decoding: body.readableBytesView, 99 | as: UTF8.self 100 | ) 101 | } 102 | } else { 103 | eventRequestBody = nil 104 | } 105 | 106 | var headerDict: [String : Any] = request.headers.reduce(into: [:]) { result, value in 107 | result[value.0] = value.1 108 | } 109 | strip(keys: configuration.keyFilters, from: &headerDict) 110 | 111 | let filteredHeaders: [(String, String)] = headerDict.compactMap { k, v in 112 | guard let value = v as? String else { return nil } 113 | return (k, value) 114 | } 115 | 116 | eventRequest = .init( 117 | body: eventRequestBody, 118 | clientIp: request.headers.forwarded.first(where: { $0.for != nil })?.for ?? request.remoteAddress?.hostname, 119 | headers: .init(uniqueKeysWithValues: filteredHeaders), 120 | httpMethod: request.method.string, 121 | referer: "n/a", 122 | url: request.url.string 123 | ) 124 | } else { 125 | breadcrumbs = [] 126 | eventRequest = nil 127 | } 128 | 129 | let exceptionStackTrace: [BugsnagPayload.Event.Exception.StackTrace] 130 | if 131 | let debuggable = error as? DebuggableError, 132 | let stackTrace = debuggable.stackTrace 133 | { 134 | exceptionStackTrace = stackTrace.frames.map { frame in 135 | .init( 136 | file: frame.description, 137 | method: frame.description, 138 | lineNumber: 0, 139 | columnNumber: 0 140 | ) 141 | } 142 | } else if 143 | let debuggable = error as? DebuggableError, 144 | let source = debuggable.source 145 | { 146 | exceptionStackTrace = [.init( 147 | file: source.readableFile, 148 | method: source.function, 149 | lineNumber: Int(source.line), 150 | columnNumber: 0 151 | )] 152 | } else { 153 | exceptionStackTrace = [] 154 | } 155 | let metadata: [String: String] 156 | let severity: BugsnagSeverity 157 | 158 | if let bugsnag = error as? BugsnagError { 159 | metadata = bugsnag.metadata.mapValues { $0.description } 160 | severity = bugsnag.severity 161 | } else { 162 | metadata = [:] 163 | severity = .error 164 | } 165 | 166 | var userID: String? 167 | if let request = self.currentRequest { 168 | for closure in self.users.storage { 169 | userID = closure(request)?.description 170 | } 171 | } 172 | 173 | let message: String 174 | let type: String 175 | if let debuggable = error as? DebuggableError { 176 | message = debuggable.reason 177 | type = debuggable.fullIdentifier 178 | } else if let abort = error as? AbortError { 179 | message = abort.reason 180 | type = "AbortError.\(abort.status)" 181 | } else { 182 | message = "\(error)" 183 | type = "Swift.Error" 184 | } 185 | 186 | return BugsnagPayload( 187 | apiKey: configuration.apiKey, 188 | events: [ 189 | BugsnagPayload.Event( 190 | app: .init( 191 | releaseStage: configuration.releaseStage, 192 | version: configuration.version 193 | ), 194 | breadcrumbs: breadcrumbs, 195 | exceptions: [ 196 | .init( 197 | errorClass: "error", 198 | message: message, 199 | stacktrace: exceptionStackTrace, 200 | type: type 201 | ) 202 | ], 203 | metaData: metadata, 204 | payloadVersion: "4", 205 | request: eventRequest, 206 | severity: severity.value, 207 | user: userID.map { .init(id: $0) } 208 | ) 209 | ], 210 | notifier: .init( 211 | name: "nodes-vapor/bugsnag", 212 | url: "https://github.com/nodes-vapor/bugsnag.git", 213 | version: "3" 214 | ) 215 | ) 216 | } 217 | 218 | private func cleaned( 219 | body: ByteBuffer, 220 | as contentType: HTTPMediaType, 221 | keyFilters: Set 222 | ) -> String? { 223 | switch contentType { 224 | case .json, .jsonAPI: 225 | if var json = try? JSONSerialization.jsonObject( 226 | with: Data(body.readableBytesView) 227 | ) as? [String: Any] { 228 | self.strip(keys: keyFilters, from: &json) 229 | let data = try! JSONSerialization.data(withJSONObject: json) 230 | return String(decoding: data, as: UTF8.self) 231 | } else { 232 | fallthrough 233 | } 234 | default: 235 | return nil 236 | } 237 | } 238 | 239 | private func strip(keys: Set, from data: inout [String: Any]) { 240 | for key in data.keys { 241 | if keys.contains(key) { 242 | data[key] = "" 243 | } else { 244 | if var nested = data[key] as? [String: Any] { 245 | self.strip(keys: keys, from: &nested) 246 | data[key] = nested 247 | } 248 | } 249 | } 250 | } 251 | } 252 | 253 | extension ErrorSource { 254 | var readableFile: String { 255 | if self.file.contains("/Sources/") { 256 | return self.file.components(separatedBy: "/Sources/").last ?? self.file 257 | } else if self.file.contains("/Tests/") { 258 | return self.file.components(separatedBy: "/Tests/").last ?? self.file 259 | } else { 260 | return self.file 261 | } 262 | } 263 | } 264 | --------------------------------------------------------------------------------