Skip to content

Commit

Permalink
Merge pull request #230 from bugsnag/PLAT-11373-defer-view-span-end
Browse files Browse the repository at this point in the history
[PLAT-11373] Add support for deferring view load span end
  • Loading branch information
kstenerud authored Dec 27, 2023
2 parents 92b1882 + 572f1dd commit 28519c3
Show file tree
Hide file tree
Showing 8 changed files with 362 additions and 45 deletions.
49 changes: 43 additions & 6 deletions Example/Example/SomeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,50 @@ import SwiftUI

@available(iOS 13.0.0, *)
struct SomeView: View {
@State var data: Data?
@State var deferringViewLoadSpan = true

var body: some View {
let span = BugsnagPerformance.startViewLoadSpan(
name: "SomeView", viewType: .swiftUI)
Text("Hello from SwiftUI 🙃")
.onAppear {
span.end()
}.bugsnagTraced("My text view")

// Fake a background data load.
// On iOS 15+ you'd use a .task()
defer {
DispatchQueue.global().async {
data = Data()
}
}

return VStack {
if data == nil {
// .bugsnagDeferEndUntilViewDisappears() will hold the current
// view load span open until this Text view disappears.
//
// When this Text view disappears, its defer is resolved.
// That would in theory leave the view load span free to end,
// but there are still other defers to resolve!
Text("SwiftUI is loading")
.bugsnagTraced("loading")
.bugsnagDeferEndUntilViewDisappears()
} else {
Text("Hello from SwiftUI 🙃")
.bugsnagTraced("loaded")

if deferringViewLoadSpan {
// Defer the view load span end until this button disappears.
Button("Stop deferring the view load span") {
deferringViewLoadSpan = false
}
.bugsnagDeferEndUntilViewDisappears()
}
}
}
.bugsnagDeferEndUntil {
// Defer the view load span end until this function returns a true value.
//
// This is technically redundant since the above button is deferring the
// view load span based on the same @State value (deferringViewLoadSpan).
return !deferringViewLoadSpan
}
}
}

Expand Down
12 changes: 6 additions & 6 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -80,19 +80,19 @@ GEM
uri_template (~> 0.7)
mime-types (3.5.1)
mime-types-data (~> 3.2015)
mime-types-data (3.2023.1003)
mime-types-data (3.2023.1205)
multi_test (0.1.2)
nokogiri (1.15.4-arm64-darwin)
nokogiri (1.15.5-arm64-darwin)
racc (~> 1.4)
nokogiri (1.15.4-x86_64-darwin)
nokogiri (1.15.5-x86_64-darwin)
racc (~> 1.4)
optimist (3.0.1)
os (1.0.1)
power_assert (2.0.3)
racc (1.7.1)
racc (1.7.3)
rack (2.2.8)
rake (12.3.3)
regexp_parser (2.8.2)
regexp_parser (2.8.3)
rexml (3.2.6)
rubyzip (2.3.2)
selenium-webdriver (4.5.0)
Expand All @@ -109,7 +109,7 @@ GEM
tomlrb (2.0.3)
unf (0.1.4)
unf_ext
unf_ext (0.0.8.2)
unf_ext (0.0.9.1)
uri_template (0.7.0)
webrick (1.7.0)
websocket (1.2.10)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import BugsnagPerformance

@available(iOS 13, macOS 10.15, tvOS 13, watchOS 6.0, *)
final class BugsnagViewContext: ObservableObject {
public var firstViewLoadSpan: BugsnagPerformanceSpan? = nil
public var isFirstBodyThisCycle = true
public var parentViewLoadSpan: BugsnagPerformanceSpan? = nil
public var unresolvedDeferCount = 0
}

@available(iOS 13, macOS 10.15, tvOS 13, watchOS 6.0, *)
Expand All @@ -36,53 +38,110 @@ extension View {

@available(iOS 13, macOS 10.15, tvOS 13, watchOS 6.0, *)
public extension View {

/**
* Trace this view through Bugsnag.
* If viewName is not specified, one will be generated based on the struct's class.
*/
func bugsnagTraced(_ viewName: String? = nil) -> some View {
return BugsnagTracedView(viewName) {
return self
}
}

/**
* Defer ending the overarching view load span until the supplied function returns true during a body build cycle.
* The view load span will not be ended until ALL deferred-end conditions are true.
*/
func bugsnagDeferEndUntil(deferUntil: @escaping ()->(Bool)) -> some View {
return BugsnagDeferredTraceEndView(deferUntil: deferUntil) {self}
}

/**
* Defer ending the overarching view load span until this view disappears from the view hierarchy.
* The view load span will not be ended until ALL deferred-end conditions are true.
*/
func bugsnagDeferEndUntilViewDisappears() -> some View {
return self.bugsnagDeferEndUntil {
return false
}
}
}

private func generateNameFromContent(content: Any) -> String {
let viewName = String(describing: content)
if let angleBracketIndex = viewName.firstIndex(of: "<") {
return String(viewName[viewName.startIndex ..< angleBracketIndex])
}
return viewName
}

@available(iOS 13, macOS 10.15, tvOS 13, watchOS 6.0, *)
public struct BugsnagTracedView<Content: View>: View {
public struct BugsnagDeferredTraceEndView<Content: View>: View {
@Environment(\.keyBSGViewContext) private var bsgViewContext: BugsnagViewContext

let content: () -> Content
let name: String
private let content: () -> Content
private let deferUntilCondition: ()->(Bool)

public init(_ viewName: String? = nil, content: @escaping () -> Content) {
public init(deferUntil: @escaping ()->(Bool), content: @escaping () -> Content) {
self.content = content
self.name = viewName ?? BugsnagTracedView.getViewName(content: Content.self)
self.deferUntilCondition = deferUntil
}

private static func getViewName(content: Any) -> String {
let viewName = String(describing: content)
if let angleBracketIndex = viewName.firstIndex(of: "<") {
return String(viewName[viewName.startIndex ..< angleBracketIndex])
public var body: some View {
if !deferUntilCondition() {
bsgViewContext.unresolvedDeferCount += 1
}
return viewName

// We're not generating our own content; merely passing through the content
// of the body we wrapped. The rendered scene will not contain any Bugsnag views.
return content()
}
}

@available(iOS 13, macOS 10.15, tvOS 13, watchOS 6.0, *)
public struct BugsnagTracedView<Content: View>: View {
@Environment(\.keyBSGViewContext) private var bsgViewContext: BugsnagViewContext

private let content: () -> Content
private let name: String

public init(_ viewName: String? = nil, content: @escaping () -> Content) {
self.content = content
self.name = viewName ?? generateNameFromContent(content: Content.self)
}

public var body: some View {
var firstViewLoadSpan = bsgViewContext.firstViewLoadSpan

if firstViewLoadSpan == nil {
let opts = BugsnagPerformanceSpanOptions()
opts.setParentContext(nil)
let thisViewLoadSpan = BugsnagPerformance.startViewLoadSpan(name: name, viewType: BugsnagPerformanceViewType.swiftUI, options: opts)
firstViewLoadSpan = thisViewLoadSpan
bsgViewContext.firstViewLoadSpan = thisViewLoadSpan
var parentViewLoadSpan = bsgViewContext.parentViewLoadSpan

if bsgViewContext.isFirstBodyThisCycle {
bsgViewContext.isFirstBodyThisCycle = false

if parentViewLoadSpan == nil {
let opts = BugsnagPerformanceSpanOptions()
opts.setParentContext(nil)
let thisViewLoadSpan = BugsnagPerformance.startViewLoadSpan(name: name, viewType: BugsnagPerformanceViewType.swiftUI, options: opts)
parentViewLoadSpan = thisViewLoadSpan
bsgViewContext.parentViewLoadSpan = thisViewLoadSpan
}

DispatchQueue.main.async {
thisViewLoadSpan.end()
if bsgViewContext.unresolvedDeferCount == 0 {
bsgViewContext.parentViewLoadSpan?.end()
bsgViewContext.parentViewLoadSpan = nil
}
bsgViewContext.unresolvedDeferCount = 0
bsgViewContext.isFirstBodyThisCycle = true
}
}

let opts = BugsnagPerformanceSpanOptions()
opts.setParentContext(firstViewLoadSpan)
let thisViewLoadSpan = BugsnagPerformance.startViewLoadPhaseSpan(name: name, phase: "body", parentContext: firstViewLoadSpan!)
defer {
thisViewLoadSpan.end()
}
opts.setParentContext(parentViewLoadSpan)
// We are actually recording a point here, not measuring a duration.
BugsnagPerformance.startViewLoadPhaseSpan(name: name, phase: "body", parentContext: parentViewLoadSpan!).end()

// We're not generating our own content; merely passing through the content
// of the body we wrapped. The rendered scene will not contain any Bugsnag views.
return content()
}
}
117 changes: 116 additions & 1 deletion features/default/automatic_spans.feature
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ Feature: Automatic instrumentation spans
* the trace payload field "resourceSpans.0.resource" string attribute "telemetry.sdk.name" equals "bugsnag.performance.cocoa"
* the trace payload field "resourceSpans.0.resource" string attribute "telemetry.sdk.version" matches the regex "[0-9]\.[0-9]\.[0-9]"

Scenario: AutoInstrumentSwiftUIScenario
Scenario: AutoInstrumentSwiftUIScenario no change
Given I run "AutoInstrumentSwiftUIScenario"
And I wait for 3 spans
Then the trace "Content-Type" header equals "application/json"
Expand All @@ -272,6 +272,121 @@ Feature: Automatic instrumentation spans
* the trace payload field "resourceSpans.0.resource" string attribute "telemetry.sdk.name" equals "bugsnag.performance.cocoa"
* the trace payload field "resourceSpans.0.resource" string attribute "telemetry.sdk.version" matches the regex "[0-9]\.[0-9]\.[0-9]"

Scenario: AutoInstrumentSwiftUIScenario with change
Given I run "AutoInstrumentSwiftUIScenario"
And I wait for 3 spans
Then the trace "Content-Type" header equals "application/json"
* the trace "Bugsnag-Sent-At" header matches the regex "^\d\d\d\d-\d\d-\d\dT\d\d:\d\d:\d\d\.\d\d\dZ$"
* a span field "name" equals "[ViewLoad/SwiftUI]/My VStack view"
* a span field "name" equals "[ViewLoadPhase/body]/My VStack view"
* a span field "name" equals "[ViewLoadPhase/body]/My Image view"
* every span field "spanId" matches the regex "^[A-Fa-f0-9]{16}$"
* every span field "traceId" matches the regex "^[A-Fa-f0-9]{32}$"
* every span field "kind" equals 1
* every span field "startTimeUnixNano" matches the regex "^[0-9]+$"
* every span field "endTimeUnixNano" matches the regex "^[0-9]+$"
* a span string attribute "bugsnag.span.category" equals "view_load"
* a span string attribute "bugsnag.span.category" equals "view_load_phase"
* a span string attribute "bugsnag.view.name" equals "My VStack view"
* a span string attribute "bugsnag.view.name" equals "My Image view"
* a span bool attribute "bugsnag.span.first_class" is true
* a span string attribute "bugsnag.view.type" equals "SwiftUI"
* the trace payload field "resourceSpans.0.resource" string attribute "service.name" equals "com.bugsnag.fixtures.PerformanceFixture"
* the trace payload field "resourceSpans.0.resource" string attribute "telemetry.sdk.name" equals "bugsnag.performance.cocoa"
* the trace payload field "resourceSpans.0.resource" string attribute "telemetry.sdk.version" matches the regex "[0-9]\.[0-9]\.[0-9]"
And I discard every trace
And I invoke "switchView"
Then I wait for 2 spans
* a span field "name" equals "[ViewLoad/SwiftUI]/Text"
* a span field "name" equals "[ViewLoadPhase/body]/Text"
* every span field "spanId" matches the regex "^[A-Fa-f0-9]{16}$"
* every span field "traceId" matches the regex "^[A-Fa-f0-9]{32}$"
* every span field "kind" equals 1
* every span field "startTimeUnixNano" matches the regex "^[0-9]+$"
* every span field "endTimeUnixNano" matches the regex "^[0-9]+$"
* a span string attribute "bugsnag.span.category" equals "view_load"
* a span string attribute "bugsnag.span.category" equals "view_load_phase"
* every span string attribute "bugsnag.view.name" equals "Text"
* a span bool attribute "bugsnag.span.first_class" is true
* a span string attribute "bugsnag.view.type" equals "SwiftUI"

Scenario: AutoInstrumentSwiftUIDeferredScenario toggleEndSpanDefer
Given I run "AutoInstrumentSwiftUIDeferredScenario"
And I wait for 2 spans
Then the trace "Content-Type" header equals "application/json"
* the trace "Bugsnag-Sent-At" header matches the regex "^\d\d\d\d-\d\d-\d\dT\d\d:\d\d:\d\d\.\d\d\dZ$"
* a span field "name" equals "[ViewLoadPhase/body]/vstack1"
* a span field "name" equals "[ViewLoadPhase/body]/text1"
* every span field "spanId" matches the regex "^[A-Fa-f0-9]{16}$"
* every span field "traceId" matches the regex "^[A-Fa-f0-9]{32}$"
* every span field "kind" equals 1
* every span field "startTimeUnixNano" matches the regex "^[0-9]+$"
* every span field "endTimeUnixNano" matches the regex "^[0-9]+$"
* every span string attribute "bugsnag.span.category" equals "view_load_phase"
* a span string attribute "bugsnag.view.name" equals "vstack1"
* a span string attribute "bugsnag.view.name" equals "text1"
* the trace payload field "resourceSpans.0.resource" string attribute "service.name" equals "com.bugsnag.fixtures.PerformanceFixture"
* the trace payload field "resourceSpans.0.resource" string attribute "telemetry.sdk.name" equals "bugsnag.performance.cocoa"
* the trace payload field "resourceSpans.0.resource" string attribute "telemetry.sdk.version" matches the regex "[0-9]\.[0-9]\.[0-9]"
Then I discard every trace
And I invoke "toggleEndSpanDefer"
And I wait for 2 spans
* a span field "name" equals "[ViewLoadPhase/body]/vstack1"
* a span field "name" equals "[ViewLoadPhase/body]/text1"
* every span field "spanId" matches the regex "^[A-Fa-f0-9]{16}$"
* every span field "traceId" matches the regex "^[A-Fa-f0-9]{32}$"
* every span field "kind" equals 1
* every span field "startTimeUnixNano" matches the regex "^[0-9]+$"
* every span field "endTimeUnixNano" matches the regex "^[0-9]+$"
* every span string attribute "bugsnag.span.category" equals "view_load_phase"
* a span string attribute "bugsnag.view.name" equals "vstack1"
* a span string attribute "bugsnag.view.name" equals "text1"

Scenario: AutoInstrumentSwiftUIDeferredScenario toggle everything
Given I run "AutoInstrumentSwiftUIDeferredScenario"
And I wait for 2 spans
Then the trace "Content-Type" header equals "application/json"
* the trace "Bugsnag-Sent-At" header matches the regex "^\d\d\d\d-\d\d-\d\dT\d\d:\d\d:\d\d\.\d\d\dZ$"
* a span field "name" equals "[ViewLoadPhase/body]/vstack1"
* a span field "name" equals "[ViewLoadPhase/body]/text1"
* every span field "spanId" matches the regex "^[A-Fa-f0-9]{16}$"
* every span field "traceId" matches the regex "^[A-Fa-f0-9]{32}$"
* every span field "kind" equals 1
* every span field "startTimeUnixNano" matches the regex "^[0-9]+$"
* every span field "endTimeUnixNano" matches the regex "^[0-9]+$"
* every span string attribute "bugsnag.span.category" equals "view_load_phase"
* a span string attribute "bugsnag.view.name" equals "vstack1"
* a span string attribute "bugsnag.view.name" equals "text1"
* the trace payload field "resourceSpans.0.resource" string attribute "service.name" equals "com.bugsnag.fixtures.PerformanceFixture"
* the trace payload field "resourceSpans.0.resource" string attribute "telemetry.sdk.name" equals "bugsnag.performance.cocoa"
* the trace payload field "resourceSpans.0.resource" string attribute "telemetry.sdk.version" matches the regex "[0-9]\.[0-9]\.[0-9]"
Then I discard every trace
And I invoke "toggleEndSpanDefer"
And I wait for 2 spans
* a span field "name" equals "[ViewLoadPhase/body]/vstack1"
* a span field "name" equals "[ViewLoadPhase/body]/text1"
* every span field "spanId" matches the regex "^[A-Fa-f0-9]{16}$"
* every span field "traceId" matches the regex "^[A-Fa-f0-9]{32}$"
* every span field "kind" equals 1
* every span field "startTimeUnixNano" matches the regex "^[0-9]+$"
* every span field "endTimeUnixNano" matches the regex "^[0-9]+$"
* every span string attribute "bugsnag.span.category" equals "view_load_phase"
* a span string attribute "bugsnag.view.name" equals "vstack1"
* a span string attribute "bugsnag.view.name" equals "text1"
Then I discard every trace
And I invoke "toggleHideText1"
And I wait for 2 spans
* a span field "name" equals "[ViewLoad/SwiftUI]/vstack1"
* a span field "name" equals "[ViewLoadPhase/body]/vstack1"
* every span field "spanId" matches the regex "^[A-Fa-f0-9]{16}$"
* every span field "traceId" matches the regex "^[A-Fa-f0-9]{32}$"
* every span field "kind" equals 1
* every span field "startTimeUnixNano" matches the regex "^[0-9]+$"
* every span field "endTimeUnixNano" matches the regex "^[0-9]+$"
* a span string attribute "bugsnag.span.category" equals "view_load"
* a span string attribute "bugsnag.span.category" equals "view_load_phase"
* every span string attribute "bugsnag.view.name" equals "vstack1"

Scenario: Automatically start a network span that has a parent
Given I run "AutoInstrumentNetworkWithParentScenario"
And I wait for 2 seconds
Expand Down
Loading

0 comments on commit 28519c3

Please sign in to comment.