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

feat: add support for speedscope rendering of Android reactnative profiles #386

Merged
merged 14 commits into from
Jan 12, 2024
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

**Features**:

- Add support for speedscope rendering of Android reactnative profiles ([#386](https://github.com/getsentry/vroom/pull/386))

**Bug Fixes**:

**Internal**:
Expand Down
208 changes: 208 additions & 0 deletions internal/profile/legacy.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ import (
"time"

"github.com/getsentry/vroom/internal/debugmeta"
"github.com/getsentry/vroom/internal/frame"
"github.com/getsentry/vroom/internal/measurements"
"github.com/getsentry/vroom/internal/metadata"
"github.com/getsentry/vroom/internal/nodetree"
"github.com/getsentry/vroom/internal/platform"
"github.com/getsentry/vroom/internal/sample"
"github.com/getsentry/vroom/internal/speedscope"
"github.com/getsentry/vroom/internal/timeutil"
"github.com/getsentry/vroom/internal/transaction"
Expand All @@ -22,8 +24,11 @@ import (
const maxProfileDurationForCallTrees = 15 * time.Second

var ErrProfileHasNoTrace = errors.New("profile has no trace")
var member void

type (
void struct{}

LegacyProfile struct {
RawProfile

Expand Down Expand Up @@ -149,6 +154,29 @@ func (p LegacyProfile) IsSampleFormat() bool {
}

func (p *LegacyProfile) Speedscope() (speedscope.Output, error) {
t, ok := p.Trace.(Android)
// this is to handle only the Reactnative (android + js)
// use case. If it's an Android profile but there is no
// js profile, we'll skip this entirely
if ok && p.JsProfile != nil && len(p.JsProfile) > 0 {
viglia marked this conversation as resolved.
Show resolved Hide resolved
st, err := unmarshalSampleProfile(p.JsProfile)
if err == nil {
// collect set of TIDs used and change main thread name
tidSet := make(map[uint64]void)
for i := range t.Threads {
tidSet[t.Threads[i].ID] = member
if t.Threads[i].Name == "main" {
t.Threads[i].Name = "android_main"
}
}

ap := sampleToAndroidFormat(st, uint64(len(t.Methods)), tidSet)
t.Events = append(t.Events, ap.Events...)
t.Methods = append(t.Methods, ap.Methods...)
t.Threads = append(t.Threads, ap.Threads...)
p.Trace = t
}
}
o, err := p.Trace.Speedscope()
if err != nil {
return speedscope.Output{}, err
Expand Down Expand Up @@ -264,3 +292,183 @@ func (p LegacyProfile) GetTransactionTags() map[string]string {
func (p LegacyProfile) GetMeasurements() map[string]measurements.Measurement {
return p.Measurements
}

// This is to be used for ReactNative JS profile only since it works based on the
// assumption that we'll only have 1 thread in the JS profile, as is the case
// for ReactNative.
// See: https://github.com/facebook/react-native-website/blob/43bc708c784be56b68a4d74711dd8824851b38f9/website/architecture/threading-model.md
func sampleToAndroidFormat(p sample.Trace, offset uint64, usedTids map[uint64]void) Android {
//var Clock Clock
var events []AndroidEvent
var methods []AndroidMethod
//var StartTime uint64
var threads []AndroidThread

var lastStack []int

methodSet := make(map[uint64]void)
threadSet := make(map[uint64]void)

newMainTID := getMainThreadIDs(p.ThreadMetadata, usedTids)

// we ignore (as we do for other platforms) the last sample
for si, sample := range p.Samples[:len(p.Samples)-1] {
eventTime := getEventTimeFromElapsedNanoseconds(sample.ElapsedSinceStartNS)
viglia marked this conversation as resolved.
Show resolved Hide resolved
currentStack := p.Stacks[sample.StackID]
i := len(lastStack) - 1
j := len(currentStack) - 1
for i >= 0 && j >= 0 {
if lastStack[i] != currentStack[j] {
break
}
i--
j--
}
// at this point we've scanned through all the common frames at the bottom
// of the stack. For any frames left in the older stack we need to generate
// an "exit" event.
// This logic applies to all samples except the 1st
if si > 0 {
for z := 0; z <= i; z++ {
frameID := lastStack[z]
offsetID := uint64(frameID) + offset

ev := AndroidEvent{
Action: ExitAction,
ThreadID: newMainTID,
MethodID: offsetID,
Time: eventTime,
}

events = append(events, ev)
}
}

// For any frames left in the current stack we need to generate
// an "enter" event.
for ; j >= 0; j-- {
frameID := currentStack[j]
offsetID := uint64(frameID) + offset

if _, exists := methodSet[offsetID]; !exists {
updateMethods(methodSet, &methods, p.Frames[frameID], offsetID)
}
if _, exists := threadSet[newMainTID]; !exists {
metadata := p.ThreadMetadata[strconv.FormatUint(sample.ThreadID, 10)]
updateThreads(threadSet, &threads, newMainTID, &metadata)
}
ev := AndroidEvent{
Action: EnterAction,
ThreadID: newMainTID,
MethodID: offsetID,
Time: eventTime,
}
events = append(events, ev)
}
lastStack = currentStack
} // end sample loop

// we use the elapsed time since start of the latest sample
// to exit all the events of the previous one
closingTimeNs := p.Samples[len(p.Samples)-1].ElapsedSinceStartNS
Copy link
Member

Choose a reason for hiding this comment

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

One thing to watch out for here is I'm not sure if you validate the sampled js profile within the react native profile against the guards in relay. For other platforms, we ensure that each thread has at least 1 sample or it will be removed from the profile. Without this guarantee, you can accidentally render an empty profile as the first and last sample will be the same and have the same timestamp.

Copy link
Contributor

Choose a reason for hiding this comment

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good point.

Yes, I've double-checked with Pierre and we are validating it in Relay so we're safe to go

https://github.com/getsentry/relay/blob/master/relay-profiling/src/android.rs#L175

eventTime := getEventTimeFromElapsedNanoseconds(closingTimeNs)

for i := 0; i < len(lastStack); i++ {
frameID := lastStack[i]
offsetID := uint64(frameID) + offset

ev := AndroidEvent{
Action: ExitAction,
ThreadID: newMainTID,
MethodID: offsetID,
Time: eventTime,
}

events = append(events, ev)
}

return Android{
Clock: DualClock,
Events: events,
Methods: methods,
Threads: threads,
}
}

func updateMethods(methodSet map[uint64]void, methods *[]AndroidMethod, fr frame.Frame, offsetID uint64) {
method := AndroidMethod{
ID: offsetID,
Name: fr.Function,
SourceFile: fr.Path,
SourceLine: fr.Line,
InApp: fr.InApp,
}
*methods = append(*methods, method)
methodSet[offsetID] = member
}

func updateThreads(threadSet map[uint64]void, threads *[]AndroidThread, threadID uint64, metadata *sample.ThreadMetadata) {
const mainThreadName = "JavaScriptThread"
thread := AndroidThread{
ID: threadID,
Name: metadata.Name,
}
// In a few other places (CallTree), we rely on the thread
// name "main" to figure out which thread data to use.
// For reactNative we want to ignore the Android "main"
// thread and instead use the js as the main one.
if thread.Name == mainThreadName {
thread.Name = "main"
}

*threads = append(*threads, thread)
threadSet[threadID] = member
}

// Native Android profile and JS profile have a thread ID in common
// As of now, we want to show them separately instead of merged
// To do so, the thread in the JS profile, will get a new ID
// that is not yet used by any of the threads in the native Android
// profile.
func getMainThreadIDs(threads map[string]sample.ThreadMetadata, usedTids map[uint64]void) uint64 {
const mainThreadName = "JavaScriptThread"
var newTid uint64
for id, threadMetadata := range threads {
if threadMetadata.Name == mainThreadName {
intNum, _ := strconv.ParseInt(id, 10, 64)
tid := uint64(intNum)
newTid = getUniqueTid(tid, usedTids)
break
}
}
return newTid
}

func getUniqueTid(tid uint64, usedTids map[uint64]void) uint64 {
for i := tid + 1; ; i++ {
if _, exists := usedTids[i]; !exists {
return i
}
}
}

func getEventTimeFromElapsedNanoseconds(ns uint64) EventTime {
return EventTime{
Monotonic: EventMonotonic{
Wall: Duration{
Secs: (ns / 1e9),
Nanos: (ns % 1e9),
},
},
}
}

func unmarshalSampleProfile(p json.RawMessage) (sample.Trace, error) {
var st sample.Trace
err := json.Unmarshal(p, &st)
if err != nil {
return sample.Trace{}, err
}

return st, nil
}
Loading
Loading