Skip to content

Commit e453aeb

Browse files
committed
init
1 parent 8f5ad5c commit e453aeb

4 files changed

Lines changed: 239 additions & 1 deletion

File tree

reader/controller/prof.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,49 @@ func (pc *ProfController) Settings(w http.ResponseWriter, r *http.Request) {
204204
pc.writeResponse(w, r, res)
205205
}
206206

207+
func (pc *ProfController) Render(w http.ResponseWriter, r *http.Request) {
208+
for _, param := range []string{"query", "from", "until"} {
209+
if len(r.URL.Query()[param]) == 0 || r.URL.Query()[param][0] == "" {
210+
defaultError(w, 400, fmt.Sprintf("Missing required parameter: %s", param))
211+
return
212+
}
213+
}
214+
215+
query := r.URL.Query()["query"][0]
216+
var from, to time.Time
217+
for _, v := range [][2]any{{"from", &from}, {"until", &to}} {
218+
strVal := r.URL.Query()[v[0].(string)][0]
219+
iVal, err := strconv.ParseInt(strVal, 10, 64)
220+
if err != nil {
221+
defaultError(w, 400, fmt.Sprintf("Invalid value for %s: %s", html.EscapeString(v[0].(string)), html.EscapeString(strVal)))
222+
return
223+
}
224+
*(v[1].(*time.Time)) = time.Unix(iVal, 0)
225+
}
226+
227+
format := r.URL.Query().Get("format")
228+
229+
if format == "dot" {
230+
dot, err := pc.ProfService.RenderDot(r.Context(), query, from, to)
231+
if err != nil {
232+
defaultError(w, 500, err.Error())
233+
return
234+
}
235+
w.Header().Set("Content-Type", "text/vnd.graphviz; charset=utf-8")
236+
w.Write([]byte(dot))
237+
return
238+
}
239+
240+
fb, err := pc.ProfService.Render(r.Context(), query, from, to)
241+
if err != nil {
242+
defaultError(w, 500, err.Error())
243+
return
244+
}
245+
246+
w.Header().Set("Content-Type", "application/json")
247+
json.NewEncoder(w).Encode(fb.FlamebearerProfileV1)
248+
}
249+
207250
func (pc *ProfController) RenderDiff(w http.ResponseWriter, r *http.Request) {
208251
for _, param := range []string{"leftQuery", "leftFrom", "leftUntil", "rightQuery", "rightFrom", "rightUntil"} {
209252
if len(r.URL.Query()[param]) == 0 || r.URL.Query()[param][0] == "" {

reader/router/prof.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,6 @@ func RouteProf(app *mux.Router, dataSession model.IDBRegistry) {
2121
app.HandleFunc(prof.QuerierService_GetProfileStats_FullMethodName, ctrl.ProfileStats).Methods("POST", "OPTIONS")
2222
app.HandleFunc(prof.SettingsService_Get_FullMethodName, ctrl.Settings).Methods("POST", "OPTIONS")
2323
app.HandleFunc(prof.QuerierService_AnalyzeQuery_FullMethodName, ctrl.AnalyzeQuery).Methods("POST", "OPTIONS")
24-
// app.HandleFunc("/pyroscope/render", ctrl.NotImplemented).Methods("GET")
24+
app.HandleFunc("/pyroscope/render", ctrl.Render).Methods("GET", "OPTIONS")
2525
app.HandleFunc("/pyroscope/render-diff", ctrl.RenderDiff).Methods("GET", "OPTIONS")
2626
}

reader/service/prof.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,75 @@ func (ps *ProfService) Settings(ctx context.Context) (*prof.GetSettingsResponse,
421421
}, nil
422422
}
423423

424+
func (ps *ProfService) Render(ctx context.Context, strQuery string, from, to time.Time) (*Flamebearer, error) {
425+
db, err := ps.DataSession.GetDB(ctx)
426+
if err != nil {
427+
return nil, err
428+
}
429+
430+
strTypeId, strScript, err := ps.detachTypeId(strQuery)
431+
if err != nil {
432+
return nil, err
433+
}
434+
435+
scripts, err := ps.parseScripts([]string{strScript})
436+
if err != nil {
437+
return nil, err
438+
}
439+
440+
typeId, err := shared.ParseTypeId(strTypeId)
441+
if err != nil {
442+
return nil, err
443+
}
444+
445+
tree, err := ps.getTree(ctx, scripts[0], &typeId, from, to, db)
446+
if err != nil {
447+
return nil, err
448+
}
449+
450+
sampleTypeUnit := fmt.Sprintf("%s:%s", typeId.SampleType, typeId.SampleUnit)
451+
levels := tree.BFS(sampleTypeUnit)
452+
453+
flameGraph := &prof.FlameGraph{
454+
Names: tree.Names,
455+
Levels: levels,
456+
Total: tree.Total()[0],
457+
MaxSelf: tree.MaxSelf()[0],
458+
}
459+
460+
return ps.flameGraphToFlameBearer(flameGraph, &typeId), nil
461+
}
462+
463+
func (ps *ProfService) RenderDot(ctx context.Context, strQuery string, from, to time.Time) (string, error) {
464+
db, err := ps.DataSession.GetDB(ctx)
465+
if err != nil {
466+
return "", err
467+
}
468+
469+
strTypeId, strScript, err := ps.detachTypeId(strQuery)
470+
if err != nil {
471+
return "", err
472+
}
473+
474+
scripts, err := ps.parseScripts([]string{strScript})
475+
if err != nil {
476+
return "", err
477+
}
478+
479+
typeId, err := shared.ParseTypeId(strTypeId)
480+
if err != nil {
481+
return "", err
482+
}
483+
484+
tree, err := ps.getTree(ctx, scripts[0], &typeId, from, to, db)
485+
if err != nil {
486+
return "", err
487+
}
488+
489+
sampleTypeUnit := fmt.Sprintf("%s:%s", typeId.SampleType, typeId.SampleUnit)
490+
return tree.ToDot(sampleTypeUnit, strTypeId), nil
491+
}
492+
424493
func (ps *ProfService) RenderDiff(ctx context.Context,
425494
strLeftQuery string, strRightQuery string,
426495
leftFrom time.Time, rightFrom time.Time,

reader/service/prof_tree.go

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package service
22

33
import (
4+
"fmt"
45
"sort"
6+
"strings"
57

68
"github.com/metrico/qryn/v4/reader/prof"
79
)
@@ -457,3 +459,127 @@ func findNode(nodeID uint64, children []*TreeNodeV2) int {
457459
}
458460
return -1
459461
}
462+
463+
// ToDot converts the tree to Graphviz DOT format suitable for flamegraph visualization and AI analysis.
464+
// sampleType should be in "type:unit" form (e.g. "cpu:nanoseconds").
465+
// profileName is used as the graph title (e.g. "process_cpu:cpu:nanoseconds:cpu:nanoseconds").
466+
func (t *Tree) ToDot(sampleType string, profileName string) string {
467+
sampleTypeIndex := -1
468+
for i, st := range t.SampleTypes {
469+
if st == sampleType {
470+
sampleTypeIndex = i
471+
break
472+
}
473+
}
474+
if sampleTypeIndex == -1 {
475+
return "digraph flamegraph {}\n"
476+
}
477+
478+
total := t.Total()
479+
var totalVal int64
480+
if len(total) > sampleTypeIndex {
481+
totalVal = total[sampleTypeIndex]
482+
}
483+
484+
pct := func(v int64) float64 {
485+
if totalVal == 0 {
486+
return 0
487+
}
488+
return float64(v) / float64(totalVal) * 100
489+
}
490+
// weight is an integer 1–100 proportional to the child's share of total samples.
491+
// Graphviz requires weight >= 1 and works best with small integers.
492+
edgeWeight := func(v int64) int {
493+
w := int(pct(v))
494+
if w < 1 {
495+
w = 1
496+
}
497+
return w
498+
}
499+
500+
// Map raw NodeIDs to compact sequential integers so Graphviz node names stay small.
501+
seqID := make(map[uint64]int)
502+
nextID := 0
503+
assignID := func(nodeID uint64) int {
504+
if id, ok := seqID[nodeID]; ok {
505+
return id
506+
}
507+
seqID[nodeID] = nextID
508+
nextID++
509+
return nextID - 1
510+
}
511+
512+
var sb strings.Builder
513+
sb.WriteString(fmt.Sprintf("digraph %q {\n", profileName))
514+
sb.WriteString(" node [shape=box fontsize=12];\n")
515+
sb.WriteString(" edge [fontsize=10];\n")
516+
rootSeq := assignID(0)
517+
sb.WriteString(fmt.Sprintf(" N%d [label=%q style=filled fillcolor=\"#eeeeee\"];\n",
518+
rootSeq, fmt.Sprintf("total\nsamples: %d (100%%)", totalVal)))
519+
520+
visited := make(map[uint64]bool)
521+
visited[0] = true
522+
queue := []uint64{0}
523+
524+
for len(queue) > 0 {
525+
parentID := queue[0]
526+
queue = queue[1:]
527+
parentSeq := seqID[parentID]
528+
529+
children, ok := t.Nodes[parentID]
530+
if !ok {
531+
continue
532+
}
533+
534+
for _, child := range children {
535+
if visited[child.NodeID] {
536+
continue
537+
}
538+
visited[child.NodeID] = true
539+
childSeq := assignID(child.NodeID)
540+
541+
name := "n/a"
542+
if idx, exists := t.NamesMap[child.FnID]; exists && idx < len(t.Names) {
543+
name = t.Names[idx]
544+
}
545+
546+
selfVal := child.Self[sampleTypeIndex]
547+
childTotal := child.Total[sampleTypeIndex]
548+
label := fmt.Sprintf("%s\ntotal: %d (%.1f%%) self: %d (%.1f%%)",
549+
name, childTotal, pct(childTotal), selfVal, pct(selfVal))
550+
551+
fillColor := heatColor(selfVal, totalVal)
552+
553+
sb.WriteString(fmt.Sprintf(" N%d [label=%q style=filled fillcolor=%q];\n",
554+
childSeq, label, fillColor))
555+
sb.WriteString(fmt.Sprintf(" N%d -> N%d [label=%q weight=%d];\n",
556+
parentSeq, childSeq,
557+
fmt.Sprintf("%.1f%%", pct(childTotal)),
558+
edgeWeight(childTotal)))
559+
560+
queue = append(queue, child.NodeID)
561+
}
562+
}
563+
564+
sb.WriteString("}\n")
565+
return sb.String()
566+
}
567+
568+
// heatColor returns a hex fill color for a DOT node based on self-sample fraction.
569+
// 0% self → light gray, 100% self → red.
570+
func heatColor(self, total int64) string {
571+
if total == 0 || self == 0 {
572+
return "#f8f8f8"
573+
}
574+
ratio := float64(self) / float64(total)
575+
if ratio > 1 {
576+
ratio = 1
577+
}
578+
r := 248 - int(ratio*float64(248-255))
579+
if r > 255 {
580+
r = 255
581+
}
582+
g := int(248 - ratio*248)
583+
b := int(248 - ratio*248)
584+
return fmt.Sprintf("#%02x%02x%02x", r, g, b)
585+
}

0 commit comments

Comments
 (0)