-
Notifications
You must be signed in to change notification settings - Fork 0
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
feat: SwiftUI View Rendering Instrumentation #20
Changes from all commits
3ef09db
7f08ddf
5d573f9
ac81129
3ebd02a
6a6b438
7b422ed
a338dc4
4b8e406
c8a031a
329c61f
b394dfc
2aa90bf
b4e5164
affe0cf
f5cf382
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,110 @@ | ||
import Foundation | ||
import OpenTelemetryApi | ||
import SwiftUI | ||
import UIKit | ||
|
||
@testable import Honeycomb | ||
|
||
private struct NestedExpensiveView: View { | ||
let delay: Double | ||
|
||
var body: some View { | ||
HStack { | ||
HoneycombInstrumentedView(name: "nested expensive text") { | ||
Text(String(timeConsumingCalculation(delay))) | ||
} | ||
} | ||
} | ||
} | ||
|
||
// lets us adjust the slider value without re-rendering | ||
// the main ExpensiveView | ||
// once the user stops editing the slider, we propagate it back | ||
// up to ExpensiveView and then that will re-render | ||
struct DelayedSlider: View { | ||
@State private var sliderDelay = 2.0 | ||
@State private var isEditing = false | ||
var delay: Binding<Double> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I love it! <3 |
||
|
||
var body: some View { | ||
Slider( | ||
value: $sliderDelay, | ||
in: 0...4, | ||
step: 0.5 | ||
) { | ||
Text("Delay") | ||
} minimumValueLabel: { | ||
Text("0") | ||
} maximumValueLabel: { | ||
Text("4") | ||
} onEditingChanged: { editing in | ||
isEditing = editing | ||
if !editing { | ||
delay.wrappedValue = sliderDelay | ||
} | ||
} | ||
} | ||
} | ||
|
||
private struct ExpensiveView: View { | ||
@State private var delay = 2.0 | ||
|
||
var body: some View { | ||
HoneycombInstrumentedView(name: "main view") { | ||
VStack { | ||
Spacer() | ||
|
||
DelayedSlider(delay: $delay) | ||
|
||
HoneycombInstrumentedView(name: "expensive text 1") { | ||
Text(timeConsumingCalculation(delay)) | ||
} | ||
|
||
HoneycombInstrumentedView(name: "expensive text 2") { | ||
Text(timeConsumingCalculation(delay)) | ||
} | ||
|
||
HoneycombInstrumentedView(name: "expensive text 3") { | ||
Text(timeConsumingCalculation(delay)) | ||
} | ||
|
||
HoneycombInstrumentedView(name: "nested expensive view") { | ||
NestedExpensiveView(delay: delay) | ||
} | ||
|
||
HoneycombInstrumentedView(name: "expensive text 4") { | ||
Text(timeConsumingCalculation(delay)) | ||
} | ||
|
||
Spacer() | ||
} | ||
} | ||
} | ||
} | ||
|
||
struct ViewInstrumentationView: View { | ||
@State private var isEnabled = false | ||
|
||
var body: some View { | ||
VStack { | ||
Toggle(isOn: $isEnabled) { Text("enable slow render") } | ||
Spacer() | ||
if isEnabled { | ||
ExpensiveView() | ||
} | ||
} | ||
.onDisappear { | ||
isEnabled = false | ||
} | ||
} | ||
} | ||
|
||
private func timeConsumingCalculation(_ delay: TimeInterval) -> String { | ||
print("starting time consuming calculation") | ||
Thread.sleep(forTimeInterval: delay) | ||
return "slow text: \(round(delay * 100) / 100)" | ||
} | ||
|
||
#Preview { | ||
ViewInstrumentationView() | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -75,3 +75,32 @@ To manually send a span: | |
|
||
The following auto-instrumentation libraries are automatically included: | ||
* [MetricKit](https://developer.apple.com/documentation/metrickit) data is automatically collected. | ||
|
||
## Manual Instrumentation | ||
### SwiftUI View Instrumentation | ||
|
||
Wrap your SwiftUI views with `HoneycombInstrumentedView(name: String)`, like so: | ||
|
||
``` | ||
var body: some View { | ||
HoneycombInstrumentedView(name: "main view") { | ||
VStack { | ||
// ... | ||
} | ||
} | ||
} | ||
``` | ||
|
||
This will measure and emit instrumentation for your View's render times, ex: | ||
|
||
![view instrumentation trace](docs/img/view-instrumentation.png) | ||
|
||
Specifically, it will emit 2 kinds of span for each view that is wrapped: | ||
|
||
`View Render` spans encompass the entire rendering process, from initialization to appearing on screen. They include the following attributes: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm surprised there aren't specific semantic conventions around spaces and capitalization in span names, but it appears there are not: https://github.com/open-telemetry/opentelemetry-specification/blob/v1.37.0/specification/trace/api.md#span Anyway, this is fine for now, and we can always tweak them later, since we're still in experimental alpha mode. We should probably just be consistent with our web sdk. |
||
- `ViewName` (string): the name passed to `HoneycombInstrumentedView` | ||
- `RenderDuration` (double): amount of time to spent initializing the contents of `HoneycombInstrumentedView` | ||
- `TotalDuration` (double): amount of time from when `HoneycombInstrumentedView.body()` is called to when the contents appear on screen | ||
|
||
`View Body` spans encompass just the `body()` call of `HoneycombInstrumentedView, and include the following attributes: | ||
- `ViewName` (string): the name passed to `HoneycombInstrumentedView` |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,67 @@ | ||
import OpenTelemetryApi | ||
import SwiftUI | ||
|
||
private let honeycombInstrumentedViewName = "@honeycombio/instrumentation-view" | ||
|
||
struct HoneycombInstrumentedView<Content: View>: View { | ||
private let span: Span | ||
private let content: () -> Content | ||
private let name: String | ||
private let initTime: Date | ||
|
||
init(name: String, @ViewBuilder _ content: @escaping () -> Content) { | ||
self.initTime = Date() | ||
self.name = name | ||
self.content = content | ||
|
||
self.span = getViewTracer().spanBuilder(spanName: "View Render") | ||
.setStartTime(time: Date()) | ||
.setAttribute(key: "ViewName", value: name) | ||
.startSpan() | ||
} | ||
|
||
var body: some View { | ||
let start = Date() | ||
|
||
// contents start init | ||
let bodySpan = getViewTracer().spanBuilder(spanName: "View Body") | ||
.setStartTime(time: Date()) | ||
.setAttribute(key: "ViewName", value: name) | ||
.setParent(span) | ||
.setActive(true) | ||
.startSpan() | ||
|
||
let c = content() | ||
// contents end init | ||
|
||
// we don't end `bodySpan` here so that it remains active in context | ||
// that way subsequent spans get nested in correctly | ||
// but we are going to want to track how long it took, so we need to store the endTime: | ||
let endTime = Date() | ||
|
||
span.setAttribute( | ||
key: "RenderDuration", | ||
value: endTime.timeIntervalSince(start) | ||
) | ||
|
||
return c.onAppear { | ||
// contents end render | ||
// we haven't ended `bodySpan` yet because we wanted it to remain active in context | ||
// now we need to end it, and we pass in the endTime from earlier, when the body actually | ||
// finished rendering. Otherwise this span would stretch out to cover the rendering time | ||
// of other views in the tree, and we wouldn't get an accurate duration. | ||
bodySpan.end(time: endTime) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm a little confused about this. Is there a reason we can't do this on line 40, right after There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wanted it to remain active in context, I put some comments in the code that should it explain it better. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. thanks for the comment. very helpful. |
||
|
||
let appearTime = Date() | ||
span.setAttribute(key: "TotalDuration", value: appearTime.timeIntervalSince(initTime)) | ||
span.end(time: appearTime) | ||
} | ||
} | ||
} | ||
|
||
func getViewTracer() -> Tracer { | ||
return OpenTelemetry.instance.tracerProvider.get( | ||
instrumentationName: honeycombInstrumentedViewName, | ||
instrumentationVersion: honeycombLibraryVersion | ||
) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -166,3 +166,19 @@ mk_diag_attr() { | |
assert_equal "$result" ' 30 "localhost"' | ||
} | ||
|
||
@test "Render Instrumentation attributes are correct" { | ||
# we got the spans we expect | ||
result=$(span_names_for "@honeycombio/instrumentation-view" | sort | uniq -c) | ||
assert_equal "$result" ' 7 "View Body" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should keep an eye on this and make sure it isn't flaky. I'm a little worried about something getting rendered an extra time for whatever reason, and the counts being off. |
||
7 "View Render"' | ||
|
||
# the View Render spans are tracking the views we expect | ||
total_duration=$(attribute_for_span_key "@honeycombio/instrumentation-view" "View Render" ViewName string | sort) | ||
assert_equal "$total_duration" '"expensive text 1" | ||
"expensive text 2" | ||
"expensive text 3" | ||
"expensive text 4" | ||
"main view" | ||
"nested expensive text" | ||
"nested expensive view"' | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -20,7 +20,7 @@ span_attributes_for() { | |
# $3 - attribute key | ||
# $4 - attribute type | ||
attribute_for_span_key() { | ||
attributes_from_span_named $1 $2 | \ | ||
attributes_from_span_named "$1" "$2" | \ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We're emitting spans named |
||
jq "select (.key == \"$3\").value" | \ | ||
jq ".${4}Value" | ||
} | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's really hard to resist the urge to bikeshed what icon to use for each tab 😆