-module(eflambe_svg). -behaviour(eflambe_output_formatter). -export([extension/0, init/1, handle_trace_event/2, finalize/2]). -record(state, { options :: eflambe:options(), stack = [] :: list(), accumulator = [] :: list(), useconds = 0 :: integer() }). -record(call, { mfa :: mfa() | atom(), depth = 0 :: integer(), start = 0 :: integer(), width = 1 :: integer() }). % Constants for text display -define(AVERAGE_CHAR_WIDTH, 0.59). -define(FONT_SIZE, 12). -define(CHAR_WIDTH, ?FONT_SIZE * ?AVERAGE_CHAR_WIDTH). % Constants for flame graph canvas -define(WIDTH, 800). -define(YPAD1, ?FONT_SIZE * 4). -define(YPAD2, (?FONT_SIZE * 2) + 10). -define(XPAD, 10). % Constants for flame graph block elements -define(BLOCK_HEIGHT, 16). -define(FRAMEPAD, 1). extension() -> {ok, <<"svg">>}. %%-------------------------------------------------------------------- %% This callback exists so the implementation can initialize its own internal %% state. %% %%-------------------------------------------------------------------- init(Options) -> {ok, #state{options = Options}}. %%-------------------------------------------------------------------- %% This callback exists so the implementation module can process each individual %% trace event. %% %%-------------------------------------------------------------------- % Anytime a call event is received and we have an empty stack push both the caller % and the call itself onto the stack handle_trace_event({trace_ts, _Pid, call, MFA, {cp, CallerMFA}, TS}, #state{stack = []} = State) -> generate_new_state(State, [MFA, CallerMFA], TS); % If there is no caller and the stack is empty just push the call itself handle_trace_event({trace_ts, _Pid, call, MFA, {cp, undefined}, TS}, #state{stack = []} = State) -> generate_new_state(State, [MFA], TS); % When the current call is the same call as the one at the top of the stack % don't change anything except the timestamp handle_trace_event({trace_ts, _Pid, call, MFA, {cp, undefined}, TS}, #state{stack = [MFA|_]} = State) -> generate_new_state(State, [MFA], TS); % When the current call is different than the one at the top of the stack push % the new call and new timestamp handle_trace_event({trace_ts, _Pid, call, MFA, {cp, undefined}, TS}, #state{stack = Stack} = State) -> generate_new_state(State, [MFA|Stack], TS); % If a function calls itself we shouldn't push a new call onto the stack. % Otherwise we could end up with infinitely tall flamegraphs. We are % effectively collapsing multiple recursive calls down into one here handle_trace_event({trace_ts, _Pid, call, MFA, {cp, MFA}, TS}, #state{stack = [MFA|Stack]} = State) -> generate_new_state(State, [MFA|Stack], TS); % Handle the case of a normal call with the calling function already on the stack handle_trace_event({trace_ts, _Pid, call, MFA, {cp, CallingMFA}, TS}, #state{stack = [CallingMFA|Stack]} = State) -> generate_new_state(State, [MFA, CallingMFA|Stack], TS); % Must have been a call from a function that is not at the top of the stack. % Move up one level and look for a match. handle_trace_event({trace_ts, _Pid, call, _MFA, {cp, _CallingMFA}, _} = Trace, #state{stack = [_|StackRest]} = State) -> handle_trace_event(Trace, State#state{stack = StackRest}); % Process asleep handle_trace_event({trace_ts, _Pid, in, _Command0, TS}, #state{stack = [sleep|Stack]} = State) -> generate_new_state(State, [sleep|Stack], TS); % Process is scheduled in, only change timestamp handle_trace_event({trace_ts, _Pid, in, _Command0, TS}, #state{stack = Stack} = State) -> generate_new_state(State, Stack, TS); % Process starts to sleep handle_trace_event({trace_ts, _Pid, out, _Command0, TS}, #state{stack = Stack} = State) -> generate_new_state(State, [sleep|Stack], TS); % Function returned to a caller higher up on the stack handle_trace_event({trace_ts, _Pid, return_to, MFA, TS}, #state{stack=[_, MFA|Stack]} = State) -> generate_new_state(State, [MFA|Stack], TS); handle_trace_event({trace_ts, _Pid, return_to, _MFA, _TS}, State) -> {ok, State}; % Ignore garbage collection minor start handle_trace_event({trace_ts, _Pid, gc_minor_start, _Stats, _TS}, #state{} = State) -> {ok, State}; % Ignore garbage collection minor end handle_trace_event({trace_ts, _Pid, gc_minor_end, _Stats, _TS}, #state{} = State) -> {ok, State}; % Ignore garbage collection major start handle_trace_event({trace_ts, _Pid, gc_major_start, _Stats, _TS}, #state{} = State) -> {ok, State}; % Ignore garbage collection major end handle_trace_event({trace_ts, _Pid, gc_major_end, _Stats, _TS}, #state{} = State) -> {ok, State}; handle_trace_event(TraceEvent, State) -> logger:error("Received unexpected trace event: ~w", [TraceEvent]), {ok, State}. %%-------------------------------------------------------------------- %% This callback exists so the implementation module can finalize processing of %% the trace data. %% %%-------------------------------------------------------------------- finalize(_Type, #state{accumulator = Acc} = State) -> % Recurse over callstacks in Acc: % Recurse over calls in callstack, starting with the top call (e.g. bottom of the flamegraph): % If matching callstack in incomplete_blocks at the same level: % Increment call length for the call on incomplete_blocks by 1 % Else if different call exists at same level: % Move call and all deeper calls on incomplete_blocks to complete_blocks % Push call to incomplete_blocks % Else no call exists at same level: % Push call to incomplete_blocks % Move calls on incomplete_blocks to complete_blocks pushing the deepest call first CallBlocks = callstacks_to_blocks(State), % We'll scale individual calls by the total width of the flame graph TotalSamples = length(Acc), WidthPerUnit = (?WIDTH - (2 * ?XPAD)) / TotalSamples, % Heigh is deteremined by the deepest call stack Depths = lists:map(fun(#call{depth=Depth}) -> Depth end, CallBlocks), MaxCallDepth = lists:max(Depths), Height = (MaxCallDepth * ?BLOCK_HEIGHT) + ?YPAD1 + ?YPAD2, % Post-process calls SvgBlocks = callblocks_to_svg(CallBlocks, TotalSamples, WidthPerUnit, Height), PrivDir = code:priv_dir(eflambe), {ok, HeaderBinary} = file:read_file(filename:join([PrivDir, "static/svg_header.svg"])), % Header elements HeaderElements = setup_svg_doc(?WIDTH, Height), {ok, [HeaderBinary, HeaderElements, SvgBlocks, <<"">>]}. %%%=================================================================== %%% Internal functions %%%=================================================================== % Helper function svg(Element) -> xmerl:export_simple_element(Element, xmerl_xml). svg_doc_elem(Width, Height) -> % We can't use xmerl here because I don't want to close the SVG doc in this % function, due to poor choices made earlier. ComputedViewbox = io_lib:format("0 0 ~B ~B", [Width, Height]), [ <<">, ComputedViewbox, <<"\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\">">> ]. setup_svg_doc(Width, Height) -> Black = "rgb(0,0,0)", % TODO: Should convert this to xmerl Gradient = <<" ">>, [ svg_doc_elem(Width, Height), Gradient, % Background gradient svg({rect, [{x, 0}, {y, 0}, {width, Width}, {height, Height}, {fill, "url(#background)"}], []}), % Title svg({text, [{'text-anchor', "middle"}, {x, floor(Width/2)}, {y, ?FONT_SIZE * 2}, {'font-size', ?FONT_SIZE + 5}, {fill, Black}], ["Flame Graph"]}), % Details hover over svg({text, [{x, ?XPAD}, {y, floor(Height - ?YPAD2 / 2)}, {'font-size', ?FONT_SIZE}, {fill, Black}, {id, "details"}], [" "]}), % Unzoom button svg({text, [{x, ?XPAD}, {y, ?FONT_SIZE * 2}, {'font-size', ?FONT_SIZE}, {fill, Black}, {id, "unzoom"}, {onclick, "unzoom()"}, {style, "opacity:0.0;cursor:pointer"}], ["Reset Zoom"]}) ]. callstacks_to_blocks(#state{accumulator = Acc}) -> callstacks_to_blocks(Acc, 0, [], []). callstacks_to_blocks([], _Position, Incomplete, Complete) -> Incomplete ++ Complete; callstacks_to_blocks([Callstack|Callstacks], Pos, Incomplete, Complete) -> {NewIncomplete, NewComplete} = apply_new_callstack(Callstack, 0, Pos, Incomplete, Complete), callstacks_to_blocks(Callstacks, Pos+1, NewIncomplete, NewComplete). apply_new_callstack([], _Depth, _Position, Incomplete, Complete) -> {Incomplete, Complete}; apply_new_callstack([MFA|Callstack], Depth, Pos, Incomplete, Complete) -> case find_same_call({MFA, Depth}, Incomplete) of undefined -> NewCall = #call{mfa=MFA, depth=Depth, start=Pos}, case call_at_depth(Depth, Incomplete) of undefined -> % Push new call apply_new_callstack(Callstack, Depth+1, Pos, [NewCall|Incomplete], Complete); _Call -> % Move call, and any deeper if they exist, to complete list and push new call {NewComplete, RemIncomplete} = pop_any_deeper(Depth, Incomplete), NewIncomplete = [NewCall|RemIncomplete], FinalComplete = NewComplete ++ Complete, apply_new_callstack(Callstack, Depth+1, Pos, NewIncomplete, FinalComplete) end; #call{width=Width} = Call -> % Update call UpdatedIncomplete = update_call(Call#call{width=Width+1}, Incomplete), apply_new_callstack(Callstack, Depth+1, Pos, UpdatedIncomplete, Complete) end. pop_any_deeper(Depth, Incomplete) -> Predicate = fun (#call{depth=CallDepth}) when CallDepth >= Depth -> true; (_) -> false end, lists:partition(Predicate, Incomplete). find_same_call({MFA, Depth}, IncompleteCalls) -> Predicate = fun(#call{depth=CallDepth, mfa=CallMFA}) when CallDepth == Depth, CallMFA == MFA -> true; (_) -> false end, case lists:filter(Predicate, IncompleteCalls) of [] -> undefined; [Call] -> Call end. call_at_depth(Depth, IncompleteCalls) -> Predicate = fun(#call{depth=CallDepth}) when CallDepth == Depth -> true; (_) -> false end, case lists:filter(Predicate, IncompleteCalls) of [] -> undefined; [Call] -> Call end. update_call(#call{mfa=MFA, depth=Depth} = Call, IncompleteCalls) -> UpdateFun = fun (#call{mfa=CallMFA, depth=CallDepth}) when CallMFA == MFA, CallDepth == Depth -> Call; (IncompleteCall) -> IncompleteCall end, lists:map(UpdateFun, IncompleteCalls). callblocks_to_svg([], _Samples, _WidthPerUnit, _Height) -> []; callblocks_to_svg([Call|Blocks], Samples, WidthPerUnit, Height) -> Svg = callblock_to_svg(Call, Samples, WidthPerUnit, Height), [Svg|callblocks_to_svg(Blocks, Samples, WidthPerUnit, Height)]. callblock_to_svg(#call{mfa=MFA, depth=Depth, start=Start, width=Width}, Samples, WidthPerUnit, Height) -> Name = generate_name(MFA, Depth), Percentage = (Width / Samples) * 100, MouseOverLabel = mouseoverlabel(Name, Width, Percentage), ColorFill = svg_color(Name), % Compute X and Y coordinates for call block SVG rectangle XStart = ?XPAD + (Start * WidthPerUnit), XEnd = ?XPAD + ((Start + Width) * WidthPerUnit), YTop = (Height - ?YPAD1) - ((Depth + 1) * ?BLOCK_HEIGHT) + ?FRAMEPAD, YBottom = (Height - ?YPAD1) - (Depth * ?BLOCK_HEIGHT), XWidth = XEnd - XStart, YHeight = YBottom - YTop, TrimmedText = trimmed_name(Name, XWidth), % Return the iolist representing the SVG group for the call block % TODO: Use xmerl here [ <<">, MouseOverLabel, <<"')\" onmouseout=\"c()\" onclick=\"zoom(this)\">">>, <<"">>, MouseOverLabel, <<">, format_number(XStart), <<"\" y=\"">>, format_number(YBottom), <<"\" width=\"">>, format_number(XWidth), <<"\" height=\"">>, format_number(YHeight), <<"\" fill=\"">>, ColorFill, <<"\" rx=\"2\" ry=\"2\" />">>, <<">, format_number(XStart), <<"\" y=\"">>, format_number(YBottom + ?FONT_SIZE), <<"\" font-size=\"">>, format_number(?FONT_SIZE), <<"\" fill=\"rgb(0,0,0)\" >">>, TrimmedText, <<"">>]. svg_color(<<"sleep">>) -> % Special blue color for sleeps "rgb(0,130,205)"; svg_color(Name) -> % This is probably the worst way to generate a different color for each % block but it is fast. Hash = crypto:hash(sha, Name), <> = Hash, % Taken from https://github.com/proger/eflame/blob/master/flamegraph.pl#L303-L306 Red = 205 + ceil((R / 255) * 50), Green = ceil((G / 255) * 220), Blue = ceil((B / 255) * 55), io_lib:format("rgb(~B,~B,~B)", [Red, Green, Blue]). generate_name({Module, Function, Arity}, _Depth) -> iolist_to_binary(io_lib:format("~s:~s/~B", [Module, Function, Arity])); generate_name(Other, _Depth) -> iolist_to_binary(io_lib:format("~s", [Other])). trimmed_name(Name, Width) -> MaxChars = floor(Width / ?CHAR_WIDTH), NameChars = string:length(Name), case NameChars =< MaxChars of true -> Name; false -> string:slice(Name, 0, MaxChars) end. mouseoverlabel(Name, NumSamples, Percentage) -> io_lib:format("~s (~B samples, ~.2f%)", [Name, NumSamples, Percentage]). generate_new_state(#state{useconds = 0} = OldState, NewStack, TS) -> {ok, OldState#state{useconds=us(TS), stack = NewStack}}; generate_new_state(#state{accumulator=Acc} = OldState, NewStack, TS) -> %Diff = us(TS) - Micro, % Copy paste StackRev = lists:reverse(NewStack), NewAcc = lists:append([StackRev], Acc), {ok, OldState#state{useconds=us(TS), accumulator=NewAcc, stack=NewStack}}. us({Mega, Secs, Micro}) -> Mega*1000*1000*1000*1000 + Secs*1000*1000 + Micro. format_number(Float) when is_float(Float) -> io_lib:format("~.2f", [Float]); format_number(Int) when is_integer(Int) -> io_lib:format("~B", [Int]).