Profiler Integration for Adapter Authors
This document is specifically for developers writing a custom UI adapter. If you're using the built-in Bubble Tea adapter, all of this is handled automatically — you can skip this.
If you're writing your own adapter for tview, tcell, termbox, raw escape codes, or anything else, this is the contract you need to fulfill to get meaningful data in the inspector.
What the Profiler Needs From You
The profiler collects three classes of data. Only one of them requires your cooperation:
| Class | Source | Requires your adapter? |
|---|---|---|
| Memory & goroutines | runtime.ReadMemStats() | No — always available |
| Boot times | Recorded during Application.Boot() | No — automatic |
| Event log | Bus wildcard "*" subscription | No — automatic |
| Hook timings | Hook Registry MetricsCollector | No — automatic |
| FPS / Update / View timing | RecordUpdate(), RecordView() | Yes — you must call these |
The bottom row is the only thing you need to worry about. The rest works regardless.
The Two Methods
// Both available via BaseAdapter, or call profiler directly if you don't embed BaseAdapter
func (b *BaseAdapter) RecordUpdate(dur time.Duration)
func (b *BaseAdapter) RecordView(dur time.Duration)Both are no-ops if the profiler isn't running (i.e., in production builds or when TAKO_PROFILER_ENABLED is false). Safe to call unconditionally.
RecordUpdate(dur)
Call this after processing a cycle of input or state changes. The "update cycle" concept maps to:
| UI Library | When to call |
|---|---|
| Bubble Tea | In Update() method, defer from start to end |
| tview | In SetInputCapture handler, wrap the key processing |
| tcell | After processing a PollEvent() event |
| Custom | After any input/event processing block |
// Bubble Tea — automatic in the built-in adapter
func (m *takoModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
start := time.Now()
defer func() { m.RecordUpdate(time.Since(start)) }()
// ...
}
// tview — in your input capture
prevCapture := a.app.GetInputCapture()
a.app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
start := time.Now()
defer func() { a.RecordUpdate(time.Since(start)) }()
a.HandleKey(tcellEventToTakoKey(event))
if prevCapture != nil {
return prevCapture(event)
}
return event
})
// tcell — in your poll loop
for {
ev := screen.PollEvent()
start := time.Now()
// ... process ev ...
a.RecordUpdate(time.Since(start))
}RecordView(dur)
Call this after rendering/drawing to the screen. This also increments the FPS counter — so it should be called once per frame, not once per widget render.
| UI Library | When to call |
|---|---|
| Bubble Tea | In View() method, defer from start to end |
| tview | SetAfterDrawFunc callback if available; otherwise approximate |
| tcell | After screen.Show() |
| Custom | After the terminal is updated |
// Bubble Tea — automatic in the built-in adapter
func (m *takoModel) View() tea.View {
start := time.Now()
defer func() { m.RecordView(time.Since(start)) }()
// ...
}
// tcell — after screen.Show()
func (a *MyAdapter) renderLoop() {
for {
start := time.Now()
a.drawEverything(screen)
screen.Show()
a.RecordView(time.Since(start))
time.Sleep(16 * time.Millisecond) // ~60fps cap
}
}
// tview — tview doesn't expose a post-draw hook cleanly
// Option 1: wrap in SetBeforeDrawFunc (measures draw start, not end — approximate)
// Option 2: skip RecordView and accept that FPS shows 0 in inspector
// Option 3: use a custom draw wrapperWhat if Your Library Doesn't Have Draw Hooks?
Some libraries (like certain tview versions) don't provide clean post-draw callbacks. Your options:
Skip it — The inspector will show
FPS: 0,Avg View: 0ms. All other metrics still work. This is perfectly acceptable.Use
SetBeforeDrawFunc— Records the start of draw but not the end. The timing will be slightly off (measures draw scheduling, not draw completion), but FPS will still increment correctly.Measure a wider cycle — Time from "received event" to "screen updated" and call
RecordViewat the end. Less precise but gives a real-world number.Use
StartEventLoopcallback — CallRecordViewinside the event loop callback. This means "one frame per event poll tick" rather than "one frame per actual render", but gives a rough FPS.
None of these are wrong. Choose the option that matches how your UI library actually works. The profiler is a development aid, not a production requirement.
InitTelemetry — Timing Matters
The profiler is registered in the container after adapters are created (during Application.Boot()). This means you can't resolve it in the constructor.
Always call InitTelemetry() at the start of Render(), not in NewMyAdapter():
// ✅ correct
func (a *MyAdapter) Render() error {
a.InitTelemetry() // ← resolves *profiler.Profiler from container
// ...
}
// ❌ wrong — profiler not registered yet when constructor runs
func NewMyAdapter(ctx *tako.Context, ...) *MyAdapter {
a := &MyAdapter{...}
a.InitTelemetry() // profiler is nil here, this is a no-op
return a
}InitTelemetry() is idempotent — calling it multiple times is safe. If the profiler isn't registered (production mode), it leaves b.Profiler as nil, and all RecordUpdate/RecordView calls become no-ops.
Full Instrumentation Example
Here's a complete, properly instrumented custom adapter using tcell:
package tcadapter
import (
"context"
"time"
"github.com/gdamore/tcell/v2"
"github.com/takoterm/tako/contracts"
"github.com/takoterm/tako/internal/router"
"github.com/takoterm/tako/internal/tako"
"github.com/takoterm/tako/pkg/adapter"
)
type TcellAdapter struct {
*adapter.BaseAdapter
screen tcell.Screen
draw func(screen tcell.Screen)
quit chan struct{}
}
func NewTcellAdapter(
ctx *tako.Context,
bus contracts.EventBus,
r *router.Router,
draw func(screen tcell.Screen),
) (*TcellAdapter, error) {
screen, err := tcell.NewScreen()
if err != nil {
return nil, err
}
if err := screen.Init(); err != nil {
return nil, err
}
return &TcellAdapter{
BaseAdapter: adapter.NewBaseAdapter(ctx, bus, r),
screen: screen,
draw: draw,
quit: make(chan struct{}),
}, nil
}
func (a *TcellAdapter) Render() error {
// Must be called here, not in constructor
a.InitTelemetry()
// Start event bus poller — triggers redraws when events arrive
a.StartEventLoop(a.Ctx.Context, 16*time.Millisecond, func(events []contracts.Event) {
a.redraw()
})
// Initial draw
a.redraw()
// Main event loop
for {
ev := a.screen.PollEvent()
updateStart := time.Now()
switch ev := ev.(type) {
case *tcell.EventKey:
key := tcellKeyToTakoKey(ev)
if key != "" {
a.HandleKey(key)
}
case *tcell.EventResize:
a.screen.Sync()
case nil:
return nil
}
a.RecordUpdate(time.Since(updateStart)) // ← instrument update
a.redraw()
}
}
func (a *TcellAdapter) redraw() {
start := time.Now()
a.screen.Clear()
a.draw(a.screen)
a.screen.Show()
a.RecordView(time.Since(start)) // ← instrument view, increments FPS
}
func (a *TcellAdapter) Stop() error {
a.screen.Fini()
return nil
}With this instrumentation, the inspector will show accurate FPS, update timing, and view timing alongside the automatic hook/memory/event data.
Up next: 05 — Plugins In Depth — the plugin system: manifest, types, dependency declarations, and the DAG sort.
