perfetto-analyzer 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,397 @@
1
+ #!/usr/bin/env python3
2
+ """Gera relatório markdown a partir dos insights do Perfetto."""
3
+
4
+ import argparse
5
+ import json
6
+ import sys
7
+ import os
8
+ import subprocess
9
+ from datetime import datetime
10
+
11
+
12
+ def _demangle(name):
13
+ """Tenta demangling de símbolos C++. Retorna o nome original em caso de falha."""
14
+ if not name.startswith("_Z"):
15
+ return name
16
+ try:
17
+ result = subprocess.run(
18
+ ["c++filt", name], capture_output=True, text=True, timeout=2
19
+ )
20
+ demangled = result.stdout.strip()
21
+ return demangled if demangled else name
22
+ except Exception:
23
+ return name
24
+
25
+
26
+ def _shorten_jvm_name(name):
27
+ """Encurta nomes JVM mantendo classe + método legíveis.
28
+
29
+ 'androidx.compose.runtime.Recomposer$runRecomposeAndApplyChanges$2$1.invoke'
30
+ → 'compose.runtime.Recomposer · runRecomposeAndApplyChanges'
31
+ """
32
+ # Separar a parte do método (após o último ponto que não é lambda)
33
+ parts = name.split(".")
34
+ if len(parts) >= 2:
35
+ method = parts[-1]
36
+ class_part = ".".join(parts[:-1])
37
+ else:
38
+ return name[:80]
39
+
40
+ # Remover sufixos de lambda ($1, $2, etc.) da classe
41
+ class_clean = class_part.split("$")[0]
42
+
43
+ # Extrair apenas o nome da classe (sem prefixo de pacote), mas manter 1-2 segmentos de contexto
44
+ class_segments = class_clean.split(".")
45
+ if len(class_segments) > 3:
46
+ class_display = ".".join(class_segments[-3:])
47
+ else:
48
+ class_display = class_clean
49
+
50
+ # Pegar o nome do método antes de qualquer $ (lambda suffix)
51
+ method_clean = method.split("$")[0]
52
+ # Remover sufixos gerados pelo Kotlin (ex: -eZhPAX0$ui_release)
53
+ method_clean = method_clean.split("-")[0]
54
+
55
+ result = f"{class_display} · {method_clean}"
56
+ return result[:90] if len(result) > 90 else result
57
+
58
+
59
+ def format_bytes(b):
60
+ if b is None:
61
+ return "N/A"
62
+ if b >= 1024 * 1024:
63
+ return f"{b / 1024 / 1024:.1f} MB"
64
+ if b >= 1024:
65
+ return f"{b / 1024:.1f} KB"
66
+ return f"{b} B"
67
+
68
+
69
+ def format_pct(v):
70
+ return f"{v:.1f}%" if v is not None else "N/A"
71
+
72
+
73
+ def severity_emoji(s):
74
+ return {"critical": "🔴", "warning": "🟡", "info": "🔵"}.get(s, "⚪")
75
+
76
+
77
+ JVM_NOISE = {"RuntimeInit", "ZygoteInit", "NativeStart", "Looper.loop"}
78
+
79
+
80
+ def generate_report(data, package, trace_type):
81
+ now = datetime.now().strftime("%Y-%m-%d %H:%M")
82
+ duration = data.get("trace_duration_seconds", 0)
83
+ recommendations = data.get("recommendations", [])
84
+ insights = data.get("insights", [])
85
+ memory = data.get("memory", {})
86
+ cpu = data.get("cpu", {})
87
+ frames = data.get("frames", {})
88
+ battery = data.get("battery", {})
89
+
90
+ lines = []
91
+
92
+ # Cabeçalho
93
+ lines += [
94
+ f"# Relatório de Performance — `{package}`",
95
+ f"",
96
+ f"**Data:** {now} | **Duração:** {duration:.0f}s | **Tipo de trace:** `{trace_type}`",
97
+ f"",
98
+ ]
99
+
100
+ # Resumo Executivo
101
+ lines += ["## Resumo Executivo", ""]
102
+ if recommendations:
103
+ for rec in recommendations[:5]:
104
+ emoji = severity_emoji(rec["severity"])
105
+ lines.append(f"{emoji} **{rec['title']}** — {rec['detail'][:120]}...")
106
+ else:
107
+ lines.append("Nenhum problema crítico detectado neste trace.")
108
+ lines.append("")
109
+
110
+ if insights:
111
+ lines += ["**Observações:**", ""]
112
+ for insight in insights[:6]:
113
+ lines.append(f"- {insight}")
114
+ lines.append("")
115
+
116
+ # Memória
117
+ lines += ["---", "", "## Memória", ""]
118
+
119
+ unreleased_mb = memory.get("total_unreleased_mb", 0)
120
+ allocated_mb = memory.get("total_allocated_mb", 0)
121
+ art_mb = memory.get("total_art_retained_mb", 0)
122
+
123
+ lines += [
124
+ f"| Métrica | Valor |",
125
+ f"|---|---|",
126
+ f"| Heap nativo não-liberado | {unreleased_mb:.1f} MB |",
127
+ f"| Total alocado (nativo) | {allocated_mb:.1f} MB |",
128
+ f"| Objetos Java/Kotlin retidos (ART) | {art_mb:.1f} MB |",
129
+ f"",
130
+ ]
131
+
132
+ native_allocs = memory.get("top_native_allocators", [])
133
+ if native_allocs:
134
+ lines += ["### Top Alocadores de Heap Nativo", ""]
135
+ lines += ["| # | Função (topo do stack) | Bytes Não-liberados | Total Alocado |"]
136
+ lines += ["|---|---|---|---|"]
137
+ for i, alloc in enumerate(native_allocs[:10]):
138
+ fn = alloc.get("stack", ["?"])[0] if alloc.get("stack") else "desconhecida"
139
+ fn = fn[:60] if len(fn) > 60 else fn
140
+ unreleased = format_bytes(alloc.get("unreleased_bytes", 0))
141
+ total = format_bytes(alloc.get("total_bytes", 0))
142
+ lines.append(f"| {i+1} | `{fn}` | {unreleased} | {total} |")
143
+ lines.append("")
144
+
145
+ art_objects = memory.get("top_art_objects", [])
146
+ if art_objects:
147
+ lines += ["### Top Objetos Java/Kotlin Retidos (ART)", ""]
148
+ lines += ["| Classe | Instâncias | Tamanho Total |"]
149
+ lines += ["|---|---|---|"]
150
+ for obj in art_objects[:10]:
151
+ name = str(obj.get("type_name", "?"))[:70]
152
+ count = obj.get("instance_count", 0)
153
+ size = format_bytes(obj.get("total_size", 0))
154
+ lines.append(f"| `{name}` | {count:,} | {size} |")
155
+ lines.append("")
156
+
157
+ # CPU
158
+ lines += ["---", "", "## CPU", ""]
159
+
160
+ total_cpu = cpu.get("total_cpu_seconds", 0)
161
+ ctx_switches = cpu.get("total_context_switches", 0)
162
+
163
+ lines += [
164
+ f"| Métrica | Valor |",
165
+ f"|---|---|",
166
+ f"| Tempo total de CPU (todos os processos) | {total_cpu:.2f}s |",
167
+ f"| Total de context switches | {ctx_switches:,} |",
168
+ f"",
169
+ ]
170
+
171
+ top_procs = cpu.get("top_processes", [])
172
+ if top_procs:
173
+ lines += ["### Processos por Tempo de CPU", ""]
174
+ lines += ["| Processo | Tempo CPU (s) | Context Switches |"]
175
+ lines += ["|---|---|---|"]
176
+ for proc in top_procs[:10]:
177
+ name = proc.get("process_name", "?")[:50]
178
+ t = proc.get("total_cpu_seconds", 0)
179
+ sw = proc.get("context_switches", 0)
180
+ lines.append(f"| `{name}` | {t:.3f}s | {sw:,} |")
181
+ lines.append("")
182
+
183
+ # Frames JVM (Kotlin/Java) — ofensores reais na cadeia de chamada
184
+ jvm_callstacks = [
185
+ j for j in cpu.get("jvm_callstacks", [])
186
+ if not any(noise in j.get("function_name", "") for noise in JVM_NOISE)
187
+ ]
188
+ if jvm_callstacks:
189
+ total_jvm = max(j.get("sample_count", 0) for j in jvm_callstacks) if jvm_callstacks else 1
190
+ lines += ["### Top Ofensores — Classes Kotlin/Java", ""]
191
+ lines += ["> `Amostras` = quantas coletas de CPU tinham esta classe ativa na pilha de chamadas.", ""]
192
+ lines += ["| Classe / Método | Amostras | % |"]
193
+ lines += ["|---|---|---|"]
194
+ for j in jvm_callstacks[:20]:
195
+ fn = j.get("function_name", "?")
196
+ count = j.get("sample_count", 0)
197
+ pct = count / total_jvm * 100 if total_jvm else 0
198
+ # Encurtar nomes longos mantendo a parte mais informativa
199
+ short = _shorten_jvm_name(fn)
200
+ lines.append(f"| `{short}` | {count} | {pct:.0f}% |")
201
+ lines.append("")
202
+
203
+ # Frames do código do app (o que o DEV pode agir)
204
+ app_callstacks = cpu.get("app_callstacks", [])
205
+ if app_callstacks:
206
+ lines += ["### Código do App — Hotspots", ""]
207
+ lines += ["> Funções do seu código que estavam ativas no momento das amostras de CPU.", ""]
208
+ lines += ["| Classe / Método | Thread | Amostras |"]
209
+ lines += ["|---|---|---|"]
210
+ for cs in app_callstacks[:20]:
211
+ fn = _shorten_jvm_name(cs.get("function_name", "?"))
212
+ thread = cs.get("thread_name", "?")[:25]
213
+ count = cs.get("sample_count", 0)
214
+ lines.append(f"| `{fn}` | `{thread}` | {count} |")
215
+ lines.append("")
216
+
217
+ # Top frames nativos (contexto de baixo nível)
218
+ callstacks = cpu.get("top_callstacks", [])
219
+ if callstacks:
220
+ total_samples = sum(cs.get("sample_count", 0) for cs in callstacks)
221
+ lines += ["### Top Frames Nativos (contexto de baixo nível)", ""]
222
+ lines += ["| Função | Thread | Amostras |"]
223
+ lines += ["|---|---|---|"]
224
+ for cs in callstacks[:10]:
225
+ fn = cs.get("function_name", "?")
226
+ fn = _demangle(fn)
227
+ fn = fn[:60] if len(fn) > 60 else fn
228
+ thread = cs.get("thread_name", "?")[:25]
229
+ count = cs.get("sample_count", 0)
230
+ lines.append(f"| `{fn}` | `{thread}` | {count} |")
231
+ lines.append("")
232
+
233
+ cpu_freqs = cpu.get("cpu_frequencies", [])
234
+ if cpu_freqs:
235
+ lines += ["### Frequência de CPU por Core", ""]
236
+ lines += ["| CPU | Frequência Média (MHz) | Frequência Máxima (MHz) |"]
237
+ lines += ["|---|---|---|"]
238
+ for f in cpu_freqs:
239
+ lines.append(f"| CPU {f.get('cpu', '?')} | {f.get('avg_freq_mhz', 0):.0f} | {f.get('max_freq_mhz', 0):.0f} |")
240
+ lines.append("")
241
+
242
+ # Frames
243
+ lines += ["---", "", "## Frames", ""]
244
+
245
+ total_frames = frames.get("total_frames", 0)
246
+ dropped_frames = frames.get("dropped_frames", 0)
247
+ dropped_pct = frames.get("dropped_pct", 0)
248
+ pcts = frames.get("frame_percentiles_ms", {})
249
+ jank_causes = frames.get("jank_causes", {})
250
+
251
+ if total_frames > 0:
252
+ # Indicador visual de qualidade
253
+ if dropped_pct < 1.0:
254
+ quality = "🟢 Boa"
255
+ elif dropped_pct < 5.0:
256
+ quality = "🟡 Aceitável"
257
+ else:
258
+ quality = "🔴 Ruim"
259
+
260
+ lines += [
261
+ f"| Métrica | Valor | Threshold |",
262
+ f"|---|---|---|",
263
+ f"| Qualidade geral | {quality} | — |",
264
+ f"| Total de frames | {total_frames:,} | — |",
265
+ f"| Frames dropados | {dropped_frames:,} ({format_pct(dropped_pct)}) | < 1% |",
266
+ f"| Frame P50 | {pcts.get('p50', 0):.1f}ms | < 8ms |",
267
+ f"| Frame P95 | {pcts.get('p95', 0):.1f}ms | < 16.67ms |",
268
+ f"| Frame P99 | {pcts.get('p99', 0):.1f}ms | < 32ms |",
269
+ f"| Frame máximo | {pcts.get('max', 0):.1f}ms | — |",
270
+ f"",
271
+ ]
272
+
273
+ if jank_causes:
274
+ lines += ["### Causas de Jank", ""]
275
+ lines += ["| Causa | Ocorrências |"]
276
+ lines += ["|---|---|"]
277
+ for cause, count in sorted(jank_causes.items(), key=lambda x: -x[1]):
278
+ lines.append(f"| {cause} | {count:,} |")
279
+ lines.append("")
280
+
281
+ jank_jvm = frames.get("jank_jvm_callstacks", [])
282
+ if jank_jvm:
283
+ jank_jvm_filtered = [
284
+ j for j in jank_jvm
285
+ if not any(n in j.get("function_name", "") for n in JVM_NOISE)
286
+ ]
287
+ if jank_jvm_filtered:
288
+ lines += ["### APIs Android ativas durante Frames Janked", ""]
289
+ lines += ["> CPU samples capturados **dentro da janela** de frames que perderam o deadline.", ""]
290
+ lines += ["| Classe / Método | Amostras |"]
291
+ lines += ["|---|---|"]
292
+ for j in jank_jvm_filtered[:15]:
293
+ short = _shorten_jvm_name(j.get("function_name", "?"))
294
+ lines.append(f"| `{short}` | {j.get('sample_count', 0)} |")
295
+ lines.append("")
296
+
297
+ jank_app = frames.get("jank_app_callstacks", [])
298
+ if jank_app:
299
+ lines += ["### Código do App durante Frames Janked", ""]
300
+ lines += ["> Funções do **seu código** que estavam ativas quando o frame atrasou.", ""]
301
+ lines += ["| Classe / Método | Thread | Amostras |"]
302
+ lines += ["|---|---|---|"]
303
+ for j in jank_app[:15]:
304
+ short = _shorten_jvm_name(j.get("function_name", "?"))
305
+ thread = j.get("thread_name", "?")[:25]
306
+ lines.append(f"| `{short}` | `{thread}` | {j.get('sample_count', 0)} |")
307
+ lines.append("")
308
+ else:
309
+ lines += ["> Nenhum dado de frame disponível neste trace.", ""]
310
+
311
+ # Battery
312
+ lines += ["---", "", "## Battery", ""]
313
+
314
+ battery_counters = battery.get("battery_counters", [])
315
+ power_rails = battery.get("power_rails", [])
316
+
317
+ charge = next((c for c in battery_counters if c.get("counter_name") == "batt_charge_uah"), None)
318
+ if charge:
319
+ delta_uah = abs(charge.get("delta", 0))
320
+ delta_mc = delta_uah * 3.6
321
+ drain_rate = delta_mc / duration if duration else 0
322
+
323
+ lines += [
324
+ f"| Métrica | Valor |",
325
+ f"|---|---|",
326
+ f"| Carga drenada | {delta_mc:.1f} mC ({delta_uah:.1f} µAh) |",
327
+ f"| Taxa de drain | {drain_rate:.2f} mC/s |",
328
+ f"| Duração do trace | {duration:.0f}s |",
329
+ f"",
330
+ ]
331
+ else:
332
+ lines += ["> Dados de bateria não disponíveis neste trace.", ""]
333
+
334
+ if power_rails:
335
+ lines += ["### Power Rails", ""]
336
+ lines += ["| Rail | Consumo Total (mW) |"]
337
+ lines += ["|---|---|"]
338
+ for rail in power_rails:
339
+ lines.append(f"| `{rail.get('rail_name', '?')}` | {rail.get('total_mw', 0):.1f} |")
340
+ lines.append("")
341
+
342
+ # Recomendações
343
+ lines += ["---", "", "## Recomendações", ""]
344
+
345
+ if recommendations:
346
+ for rec in recommendations:
347
+ emoji = severity_emoji(rec["severity"])
348
+ lines += [
349
+ f"### {rec['priority']}. {emoji} {rec['title']}",
350
+ f"",
351
+ f"**Problema:** {rec['detail']}",
352
+ f"",
353
+ f"**Ação:** {rec['action']}",
354
+ f"",
355
+ ]
356
+ else:
357
+ lines += ["Nenhum problema detectado automaticamente. Revise o trace manualmente no [Perfetto UI](https://perfetto.dev/ui).", ""]
358
+
359
+ # Próximos Passos
360
+ lines += [
361
+ "---",
362
+ "",
363
+ "## Próximos Passos",
364
+ "",
365
+ "- [ ] Abrir o arquivo `.pb` em [perfetto.dev/ui](https://perfetto.dev/ui) para análise visual",
366
+ "- [ ] Usar o SQL Explorer do Perfetto UI para queries customizadas",
367
+ "- [ ] Correlacionar picos de CPU/memória com interações do usuário na timeline",
368
+ "- [ ] Comparar este trace com um baseline (versão anterior do app) para medir regressões",
369
+ "",
370
+ "---",
371
+ "",
372
+ f"*Gerado por perfetto-analyzer em {now}*",
373
+ ]
374
+
375
+ return "\n".join(lines)
376
+
377
+
378
+ def main():
379
+ parser = argparse.ArgumentParser(description="Gera relatório markdown de análise Perfetto")
380
+ parser.add_argument("--insights", default="/tmp/perfetto_insights.json", help="JSON de insights")
381
+ parser.add_argument("--package", default="unknown", help="Package do app")
382
+ parser.add_argument("--trace-type", default="all", help="Tipo de trace")
383
+ args = parser.parse_args()
384
+
385
+ if not os.path.exists(args.insights):
386
+ print(f"ERRO: Arquivo não encontrado: {args.insights}", file=sys.stderr)
387
+ sys.exit(1)
388
+
389
+ with open(args.insights) as f:
390
+ data = json.load(f)
391
+
392
+ report = generate_report(data, args.package, args.trace_type)
393
+ print(report)
394
+
395
+
396
+ if __name__ == "__main__":
397
+ main()