Token Measurement + Observability Implementation Plan¶
For agentic workers: this plan is executed with the superpowers:subagent-driven-development sub-skill (REQUIRED SUB-SKILL: superpowers:subagent-driven-development). Each
### Task Nis a self-contained TDD unit with its own failing-test -> implement -> pass -> commit cycle. Tasks 1-3 (wrapper) and Task 4-5 (operator) and Task 6 (observability) are in three separate repos; the deploy tasks 7-9 are sequenced last. Run tasks in the given order (later tasks depend on earlier metric-label shapes). Do NOT batch a code task with its deploy task.
Goal¶
Component 6 (Measurement) of the token-conservation redesign (tatara/docs/superpowers/specs/2026-07-04-token-conservation-design.md). This is the prerequisite instrument and the gate for component 4 (cache reclaim). Deliver: (a) wrapper token+cost Prometheus metrics carrying kind/repo/project labels; (b) the operator pod env that exports those label values; © a tatara-observability $-spend-per-kind/repo dashboard panel and a cache-hit-ratio panel; (d) confirmation that the operator-side operator_task_tokens_total family emits live series. Acceptance gate: ccw_turn_tokens_total{type="cache_read"} is a visible non-empty series for a fresh pod's turn-0.
Architecture¶
The wrapper pod is short-lived: it pushes its ccw_*/tatara_wrapper_* metric families to the operator's push-receiver (OPERATOR_PUSH_URL -> /internal/metrics/push), which unions each family's label set, stamps run_id/pod/job, ages series out on a TTL, and re-exposes them on the operator's own /metrics for normal Prometheus scrape (tatara-operator/internal/pushmetrics/receiver.go). Therefore adding kind/repo/project labels at the wrapper emit site flows through to Prometheus without any receiver change (the receiver already computes the label union per family and forwards the ccw_ prefix).
- Token/cost metrics are emitted in the wrapper at
tatara-claude-code-wrapper/internal/session/session.go:957(meterTokens). Today:TurnTokensTotallabelstype,model;TurnCostUSDis an unlabelledprometheus.Counter. This plan addskind,repo,projectto both. - The label VALUES are per-pod constants (one Task per pod). They arrive as pod env set by the operator:
TATARA_PROJECTalready exists (tatara-operator/internal/agent/pod.go:434);TATARA_KINDandTATARA_REPOare added next to it. The wrapper reads them incmd/wrapper/config.go, threads them intosession.Config, andmeterTokenssupplies them at emit. - The operator-side family
operator_task_tokens_total(tatara-operator/internal/obs/operator_metrics.go:264) is emitted atinternal/controller/turncallback.go:290on every recorded turn-complete; it is confirmed live (Task 5), not re-wired. - Dashboards are Terraform-provisioned:
tatara-observability/dashboards.tfrendersdashboards/task-delivery.jsoninto the GrafanaTatarafolder on merge tomain(.github/workflows/apply.ymlrunsterraform apply). New panels are JSON edits to that file.
Tech Stack¶
- Go 1.25 (wrapper), Go 1.26 (operator).
prometheus/client_golang,stretchr/testify. Build/test viamise exec -- go test ./.... - Terraform + Grafana provider (observability). Dashboards as JSON.
- Deploy via tatara-helmfile GitOps (chart version +
image.tagbump per release); observability applies via its own GitHub Actions on merge tomain.
Global Constraints¶
- Newest stable Go; pin the exact minor in go.mod. gofmt + golangci-lint must pass. Wrap errors with
%w. Table-driven tests witht.Run. - KISS. No tech debt; if complex, note rationale in MEMORY.md.
- JSON logs via stdlib log/slog. Expose /metrics Prometheus on every service. Log business actions at INFO with structured fields.
- Charts via
helm createthen edited, never hand-rolled. Charts cluster-agnostic. - values.yaml rule: NO plain ENVs, NO lists in values.yaml. camelCase scalar in values.yaml -> kebab-case key in ConfigMap/Secret -> workload consumes via envFrom. List-shaped data goes into a templated ConfigMap read at runtime. (CRD spec fields are exempt - typed API fields, not helm values; a map[string]string on a CRD spec is fine.)
- Model IDs are authoritative literals: claude-opus-4-8 (Opus), claude-sonnet-5 (Sonnet). Effort enum: low|medium|high|xhigh|max.
- Deploy ONLY via tatara-helmfile GitOps: merge component repo main (CI builds+pushes image/chart), then a tatara-helmfile MR bumping BOTH the chart version AND the pinned image.tag for the release. Never kubectl set-image/patch to ship. Project CR value changes are tatara-helmfile values edits.
- Branch flow: worktree off main -> develop -> merge to component main -> deploy from main only.
Task 1: Wrapper metrics carry kind/repo/project labels¶
Files: - Modify: tatara-claude-code-wrapper/internal/metrics/metrics.go:48 (label list of TurnTokensTotal), :49 (TurnCostUSD type), :123-126 (registrations), :6-49 (struct field type for TurnCostUSD). - Test: tatara-claude-code-wrapper/internal/metrics/metrics_test.go (append a table test).
Interfaces: - Produces: Metrics.TurnTokensTotal *prometheus.CounterVec with labels []string{"type","model","kind","repo","project"}. - Produces: Metrics.TurnCostUSD *prometheus.CounterVec (was prometheus.Counter) with labels []string{"kind","repo","project"}. - Consumed by: session.Manager.meterTokens (Task 3).
- Step 1: Write the failing table test. Append to
internal/metrics/metrics_test.go:
// TestMetrics_TokenCostLabels asserts the token+cost metrics carry the
// kind/repo/project labels sourced from the pod env (component 6). Table-driven
// over both families.
func TestMetrics_TokenCostLabels(t *testing.T) {
reg := prometheus.NewRegistry()
m := metrics.New(reg)
m.TurnTokensTotal.WithLabelValues("cache_read", "claude-sonnet-5", "review", "tatara-operator", "tatara").Add(42)
m.TurnCostUSD.WithLabelValues("review", "tatara-operator", "tatara").Add(0.5)
mfs, err := reg.Gather()
require.NoError(t, err)
byName := map[string]*dto.MetricFamily{}
for _, mf := range mfs {
byName[mf.GetName()] = mf
}
cases := []struct {
metric string
wantLabels map[string]string
wantValue float64
}{
{
metric: "ccw_turn_tokens_total",
wantLabels: map[string]string{"type": "cache_read", "model": "claude-sonnet-5", "kind": "review", "repo": "tatara-operator", "project": "tatara"},
wantValue: 42,
},
{
metric: "ccw_turn_cost_usd_total",
wantLabels: map[string]string{"kind": "review", "repo": "tatara-operator", "project": "tatara"},
wantValue: 0.5,
},
}
for _, tc := range cases {
t.Run(tc.metric, func(t *testing.T) {
mf := byName[tc.metric]
require.NotNil(t, mf, "family %s not registered", tc.metric)
require.Len(t, mf.GetMetric(), 1)
got := map[string]string{}
for _, lp := range mf.GetMetric()[0].GetLabel() {
got[lp.GetName()] = lp.GetValue()
}
require.Equal(t, tc.wantLabels, got)
require.Equal(t, tc.wantValue, mf.GetMetric()[0].GetCounter().GetValue())
})
}
}
Add the dto import to the test file's import block:
- Step 2: Run it, expect FAIL (compile error).
TurnCostUSDis still aprometheus.Counter, soWithLabelValuesdoes not exist and the label arity onTurnTokensTotalis wrong.
Expected: ./metrics_test.go:NN:XX: m.TurnCostUSD.WithLabelValues undefined (type prometheus.Counter has no field or method WithLabelValues) and/or a inconsistent label cardinality: expected 2 label values but got 5 panic.
- Step 3: Change the struct field type. In
internal/metrics/metrics.goedit theTurnCostUSDfield (line 49):
TurnTokensTotal *prometheus.CounterVec // labels: type=input|output|cache_read|cache_creation, model, kind, repo, project
TurnCostUSD *prometheus.CounterVec // labels: kind, repo, project (from result.json total_cost_usd)
- Step 4: Extend the registrations. In
Newchange the two collector constructors (lines 123-126):
TurnTokensTotal: prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "ccw_turn_tokens_total", Help: "Claude tokens consumed per turn, summed across the turn, by token type, model, Task kind, repo, and project."}, []string{"type", "model", "kind", "repo", "project"}),
TurnCostUSD: prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "ccw_turn_cost_usd_total", Help: "Cumulative Claude turn cost in USD (from result.json total_cost_usd when present), by Task kind, repo, and project."}, []string{"kind", "repo", "project"}),
m.TurnCostUSD is already in the reg.MustRegister(...) list (line 144); no change needed there (a *CounterVec is a Collector just like Counter).
- Step 5: Run it, expect PASS.
Expected: ok github.com/szymonrychu/tatara-claude-code-wrapper/internal/metrics. (The wrapper will not yet compile as a whole - session.go still calls the old TurnCostUSD.Add / 2-arg TurnTokensTotal; that is fixed in Task 3. Scope this task's verification to the internal/metrics package.)
- Step 6: Commit.
git add internal/metrics/metrics.go internal/metrics/metrics_test.go
git commit -m "feat: add kind/repo/project labels to wrapper token+cost metrics"
Task 2: Wrapper config reads TATARA_KIND / TATARA_REPO / TATARA_PROJECT¶
Files: - Modify: tatara-claude-code-wrapper/cmd/wrapper/config.go:14-94 (add three fields), :121-181 (populate them). - Test: tatara-claude-code-wrapper/cmd/wrapper/config_test.go (append).
Interfaces: - Produces: config.Kind, config.RepoName, config.Project string sourced from env TATARA_KIND, TATARA_REPO, TATARA_PROJECT (default ""). - Consumed by: app.go session wiring (Task 3).
- Step 1: Write the failing test. Append to
cmd/wrapper/config_test.go:
func TestLoadConfig_MetricLabelEnv(t *testing.T) {
t.Setenv("TATARA_KIND", "review")
t.Setenv("TATARA_REPO", "tatara-operator")
t.Setenv("TATARA_PROJECT", "tatara")
cfg, err := loadConfig(nil)
require.NoError(t, err)
require.Equal(t, "review", cfg.Kind)
require.Equal(t, "tatara-operator", cfg.RepoName)
require.Equal(t, "tatara", cfg.Project)
}
func TestLoadConfig_MetricLabelEnv_DefaultsEmpty(t *testing.T) {
t.Setenv("TATARA_KIND", "")
t.Setenv("TATARA_REPO", "")
t.Setenv("TATARA_PROJECT", "")
cfg, err := loadConfig(nil)
require.NoError(t, err)
require.Equal(t, "", cfg.Kind)
require.Equal(t, "", cfg.RepoName)
require.Equal(t, "", cfg.Project)
}
If config_test.go does not already import testify require, add "github.com/stretchr/testify/require" to its import block. Confirm the package clause matches the existing file (package main).
- Step 2: Run it, expect FAIL (compile error).
Expected: cfg.Kind undefined (type config has no field or method Kind).
- Step 3: Add the fields. In
cmd/wrapper/config.go, inside theconfigstruct, after thePodName stringfield (line 36 area), add:
// Metric identity labels (component 6): the operator sets these on the pod
// env so the wrapper's per-turn token/cost metrics attribute spend to a
// Task kind, repo, and project. Empty for values the operator does not set
// (e.g. RepoName is empty for project-scoped kinds).
Kind string
RepoName string
Project string
- Step 4: Populate them. In
loadConfig, inside thecfg := config{...}literal (afterPodName: envOr("POD_NAME", ""), line 140), add:
Kind: envOr("TATARA_KIND", ""),
RepoName: envOr("TATARA_REPO", ""),
Project: envOr("TATARA_PROJECT", ""),
- Step 5: Run it, expect PASS.
Expected: ok github.com/szymonrychu/tatara-claude-code-wrapper/cmd/wrapper.
- Step 6: Commit.
git add cmd/wrapper/config.go cmd/wrapper/config_test.go
git commit -m "feat: wrapper config reads TATARA_KIND/TATARA_REPO/TATARA_PROJECT metric labels"
Task 3: meterTokens emits the labels; wire config -> session.Config¶
Files: - Modify: tatara-claude-code-wrapper/internal/session/session.go (session.Config struct add fields; meterTokens at :957 supply labels). - Modify: tatara-claude-code-wrapper/cmd/wrapper/app.go:143-154 (pass the three fields into session.Config). - Test: tatara-claude-code-wrapper/internal/session/session_test.go (append a meterTokens test).
Interfaces: - Consumes: session.Config.Kind, .RepoName, .Project string. - Consumes: Metrics.TurnTokensTotal.WithLabelValues(type, model, kind, repo, project), Metrics.TurnCostUSD.WithLabelValues(kind, repo, project) (Task 1). - Produces: labelled series on turn-complete.
- Step 1: Write the failing test. Append to
internal/session/session_test.go:
func TestMeterTokens_EmitsIdentityLabels(t *testing.T) {
reg := prometheus.NewRegistry()
m := metrics.New(reg)
mgr := session.New(
session.Config{Kind: "implement", RepoName: "tatara-operator", Project: "tatara"},
turn.NewStore(), m, slog.New(slog.NewTextHandler(io.Discard, nil)),
time.Now, func() string { return "t1" },
)
mgr.MeterTokensForTest(session.HookResult{
TurnTokens: []session.TurnTokens{
{Model: "claude-opus-4-8", Input: 100, Output: 20, CacheRead: 500, CacheCreation: 30},
},
ResultJSON: json.RawMessage(`{"total_cost_usd": 0.25}`),
})
mfs, err := reg.Gather()
require.NoError(t, err)
byName := map[string]*dto.MetricFamily{}
for _, mf := range mfs {
byName[mf.GetName()] = mf
}
// cache_read series carries the identity labels + value 500.
tok := byName["ccw_turn_tokens_total"]
require.NotNil(t, tok)
var cacheRead *dto.Metric
for _, mc := range tok.GetMetric() {
lbl := map[string]string{}
for _, lp := range mc.GetLabel() {
lbl[lp.GetName()] = lp.GetValue()
}
if lbl["type"] == "cache_read" {
cacheRead = mc
require.Equal(t, "implement", lbl["kind"])
require.Equal(t, "tatara-operator", lbl["repo"])
require.Equal(t, "tatara", lbl["project"])
require.Equal(t, "claude-opus-4-8", lbl["model"])
}
}
require.NotNil(t, cacheRead, "cache_read series missing")
require.Equal(t, float64(500), cacheRead.GetCounter().GetValue())
// cost carries the identity labels + value 0.25.
cost := byName["ccw_turn_cost_usd_total"]
require.NotNil(t, cost)
require.Len(t, cost.GetMetric(), 1)
lbl := map[string]string{}
for _, lp := range cost.GetMetric()[0].GetLabel() {
lbl[lp.GetName()] = lp.GetValue()
}
require.Equal(t, map[string]string{"kind": "implement", "repo": "tatara-operator", "project": "tatara"}, lbl)
require.Equal(t, 0.25, cost.GetMetric()[0].GetCounter().GetValue())
}
Ensure the test file imports: "encoding/json", "io", "log/slog", "time", dto "github.com/prometheus/client_model/go", "github.com/prometheus/client_golang/prometheus", "github.com/stretchr/testify/require", the metrics, session, and turn packages (match the existing module import paths already used in the test files). This test lives in the external session_test package (it references session.New); meterTokens is unexported, so Step 3 adds a tiny exported test shim.
- Step 2: Run it, expect FAIL (compile error).
Expected: mgr.MeterTokensForTest undefined and unknown field Kind in struct literal of type session.Config.
- Step 3: Add the session.Config fields + the emit change + test shim. In
internal/session/session.go, add to theConfigstruct (afterRepo string, line ~/type Config):
// Kind, RepoName, Project are the pod's metric-identity labels (component 6),
// set once by the operator env and stamped onto every per-turn token/cost
// series so spend attributes to a Task kind, repo, and project.
Kind string
RepoName string
Project string
Change meterTokens (lines 957-978) to supply the labels:
func (mgr *Manager) meterTokens(r HookResult) {
kind, repo, project := mgr.cfg.Kind, mgr.cfg.RepoName, mgr.cfg.Project
for _, t := range r.TurnTokens {
model := t.Model
if model == "" {
model = "unknown"
}
mgr.m.TurnTokensTotal.WithLabelValues("input", model, kind, repo, project).Add(float64(t.Input))
mgr.m.TurnTokensTotal.WithLabelValues("output", model, kind, repo, project).Add(float64(t.Output))
mgr.m.TurnTokensTotal.WithLabelValues("cache_read", model, kind, repo, project).Add(float64(t.CacheRead))
mgr.m.TurnTokensTotal.WithLabelValues("cache_creation", model, kind, repo, project).Add(float64(t.CacheCreation))
}
if len(r.ResultJSON) > 0 {
var rj struct {
TotalCostUSD *float64 `json:"total_cost_usd"`
}
if err := json.Unmarshal(r.ResultJSON, &rj); err != nil {
mgr.log.Warn("turn cost: malformed result.json, skipping", "err", err)
} else if rj.TotalCostUSD != nil {
mgr.m.TurnCostUSD.WithLabelValues(kind, repo, project).Add(*rj.TotalCostUSD)
}
}
}
// MeterTokensForTest exposes meterTokens to the external session_test package so
// the metric-label wiring can be asserted without driving a full turn.
func (mgr *Manager) MeterTokensForTest(r HookResult) { mgr.meterTokens(r) }
- Step 4: Run it, expect PASS.
Expected: ok github.com/szymonrychu/tatara-claude-code-wrapper/internal/session.
- Step 5: Wire the config into session.Config in app.go. In
cmd/wrapper/app.go, extend thesession.New(session.Config{...})literal (lines 143-154) to pass the three fields (add afterRepo: repo,):
- Step 6: Build + full test the wrapper (whole module now compiles).
Expected: build succeeds, all packages ok, linter clean.
- Step 7: Commit.
git add internal/session/session.go cmd/wrapper/app.go internal/session/session_test.go
git commit -m "feat: stamp kind/repo/project on wrapper per-turn token+cost metrics"
Task 4: Operator pod env exports TATARA_KIND + TATARA_REPO¶
Files: - Modify: tatara-operator/internal/agent/pod.go:432-434 (add two env vars next to TATARA_TASK/TATARA_PROJECT). - Test: tatara-operator/internal/agent/pod_test.go (append a test).
Interfaces: - Produces: pod env TATARA_KIND = task.Spec.Kind, TATARA_REPO = task.Spec.RepositoryRef (both may be "" for project-scoped kinds). These match the label values the operator uses for operator_task_tokens_total (taskTokenLabels at internal/controller/turncallback.go:435), so wrapper and operator token series align on kind/repo/project. - Consumed by: wrapper cmd/wrapper/config.go (Task 2).
- Step 1: Write the failing test. Append to
internal/agent/pod_test.go:
func TestBuildPod_MetricIdentityEnv(t *testing.T) {
proj, repo, task, cfg := sampleInputs()
task.Spec.Kind = "review"
task.Spec.RepositoryRef = "repo1"
c := agent.BuildPod(proj, repo, task, nil, testMemoryEndpoint, cfg).Spec.Containers[0]
kind, ok := envValue(c, "TATARA_KIND")
require.True(t, ok, "TATARA_KIND must be set")
require.Equal(t, "review", kind)
r, ok := envValue(c, "TATARA_REPO")
require.True(t, ok, "TATARA_REPO must be set")
require.Equal(t, "repo1", r)
}
func TestBuildPod_MetricIdentityEnv_ProjectScopedEmptyRepo(t *testing.T) {
proj, _, task, cfg := sampleInputs()
task.Spec.Kind = "brainstorm"
task.Spec.RepositoryRef = ""
// Project-scoped kind: repo == nil, RepositoryRef empty.
c := agent.BuildPod(proj, nil, task, nil, testMemoryEndpoint, cfg).Spec.Containers[0]
kind, ok := envValue(c, "TATARA_KIND")
require.True(t, ok)
require.Equal(t, "brainstorm", kind)
r, ok := envValue(c, "TATARA_REPO")
require.True(t, ok, "TATARA_REPO must be present even when empty")
require.Equal(t, "", r)
}
- Step 2: Run it, expect FAIL.
Expected: TATARA_KIND must be set (envValue returns ok=false).
- Step 3: Add the env vars. In
internal/agent/pod.go, inside theenv = append(env, []corev1.EnvVar{...}block, immediately after the{Name: "TATARA_PROJECT", Value: project.Name},line (line 434), add:
// Metric identity (component 6): the wrapper stamps these onto its
// per-turn token/cost series so fleet spend attributes to a Task kind
// and repo. Same values the operator uses for operator_task_tokens_total
// (taskTokenLabels), so the two token families align. Empty repo for
// project-scoped kinds (brainstorm/refine/incident/healthCheck).
{Name: "TATARA_KIND", Value: task.Spec.Kind},
{Name: "TATARA_REPO", Value: task.Spec.RepositoryRef},
- Step 4: Run it, expect PASS.
Expected: ok github.com/szymonrychu/tatara-operator/internal/agent.
- Step 5: Full package test + build + lint.
mise exec -- go build ./...
mise exec -- go test ./internal/agent/ ./internal/controller/
mise exec -- golangci-lint run
Expected: build succeeds, both packages ok, linter clean.
- Step 6: Commit.
git add internal/agent/pod.go internal/agent/pod_test.go
git commit -m "feat: export TATARA_KIND/TATARA_REPO on agent pod env for wrapper metrics"
Task 5: Confirm operator_task_tokens_total emits live series¶
Files: - Modify: tatara-operator/internal/controller/turncallback_test.go (append a call-site test asserting the metric is bumped on a recorded turn-complete). - No production code change: the emit already exists at internal/controller/turncallback.go:288-291 (if recorded && s.Metrics != nil { s.Metrics.AddTaskTokens(...) }). This task is the "confirm it emits" branch of the spec; the alternative "wire it" branch is not needed.
Interfaces: - Consumes: taskTokenLabels(task) and OperatorMetrics.AddTaskTokens(project, repo, kind, issue, input, output). - Produces: a regression test proving the call site fires, plus a live-Prometheus acceptance check (Step 4).
- Step 1: Read the call site to confirm the guard. Confirm
internal/controller/turncallback.golines 285-292 read (no change, just verify against the current tree):
if recorded && s.Metrics != nil {
project, repo, kind, issue := taskTokenLabels(task)
s.Metrics.AddTaskTokens(project, repo, kind, issue, inputTotal, u.OutputTokens)
s.Metrics.AddTaskTurn(project, repo, kind, issue)
}
If this block differs, STOP and reconcile: the emit path is the spec's precondition. (As of this plan it is present and correct.)
- Step 2: Write a call-site regression test. The test file is
package controllerand drives the real envtest harness over the HTTP handler (seeTestTurnComplete_RecordsResultAndRequeuesatturncallback_test.go:27as the template):newCallbackServer()builds a*CallbackServerwithClient: k8sClient,Namespace: testNS, and a freshobs.NewOperatorMetrics(prometheus.NewRegistry());mkTaskProject/mkTaskRepository/mkTask/mkSubtask/annotateseed the CRs; a POST to/internal/turn-completecarrying ausageobject drivesrecordUsage->AddTaskTokens. BecausenewCallbackServer()hides its registry, build the server inline so this test owns the registry it asserts on. Append:
func TestTurnComplete_EmitsTaskTokens(t *testing.T) {
mkTaskProject(t, "p-tok", 3)
mkTaskRepository(t, "r-tok", "p-tok")
mkTask(t, "t-tok", "p-tok", "r-tok")
mkSubtask(t, "t-tok-s1", "t-tok", 1)
// Set a Kind + issue source so the emitted series carries real labels.
tk := getTask(t, "t-tok")
tk.Spec.Kind = "implement"
tk.Spec.Source = &tatarav1alpha1.TaskSource{IssueRef: "szymonrychu/tatara-operator#7"}
if err := k8sClient.Update(context.Background(), tk); err != nil {
t.Fatalf("set kind/source: %v", err)
}
annotate(t, "t-tok", map[string]string{
annCurrentTurn: "turn-1",
annCurrentSubtask: "t-tok-s1",
})
reg := prometheus.NewRegistry()
cb := &CallbackServer{Client: k8sClient, Metrics: obs.NewOperatorMetrics(reg), Namespace: testNS}
body, _ := json.Marshal(map[string]any{
"turnId": "turn-1", "state": "completed",
"finalText": "done", "stopReason": "end_turn",
"usage": map[string]any{"input_tokens": 1200, "output_tokens": 300},
})
req := httptest.NewRequest(http.MethodPost, "/internal/turn-complete", bytes.NewReader(body))
w := httptest.NewRecorder()
cb.Handler().ServeHTTP(w, req)
if w.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204; body=%s", w.Code, w.Body.String())
}
mfs, err := reg.Gather()
if err != nil {
t.Fatalf("gather: %v", err)
}
var input float64
found := false
for _, mf := range mfs {
if mf.GetName() != "operator_task_tokens_total" {
continue
}
for _, mc := range mf.GetMetric() {
lbl := map[string]string{}
for _, lp := range mc.GetLabel() {
lbl[lp.GetName()] = lp.GetValue()
}
if lbl["kind"] == "implement" && lbl["type"] == "input" && lbl["repo"] == "r-tok" {
input = mc.GetCounter().GetValue()
found = true
}
}
}
if !found {
t.Fatal("operator_task_tokens_total{kind=implement,type=input,repo=r-tok} not emitted")
}
if input != 1200 {
t.Errorf("input tokens = %v, want 1200", input)
}
}
Notes for the executor: confirm the exact mkTaskProject/mkTaskRepository/ mkTask/mkSubtask/getTask/annotate signatures against the current test file (they are used verbatim by the sibling tests, e.g. lines 28-35, 49, 53). The usage payload keys (input_tokens/output_tokens) match turnUsage (turncallback.go:130-135). repo label = RepositoryRef = r-tok.
-
Step 3: No production code change. The emit already exists at
turncallback.go:288-291. Do not add a metric accessor - the test asserts viareg.Gather()over the registry it owns. This keeps the confirmation zero-footprint on production code (spec's "confirm it emits" branch). -
Step 4: Run it, expect PASS.
Expected: ok github.com/szymonrychu/tatara-operator/internal/controller. This proves the HTTP turn-complete path bumps operator_task_tokens_total with the aligned project/repo/kind/issue/type labels. (Requires the envtest control plane the package's TestMain/k8sClient already sets up; run with the package's normal test invocation.)
- Step 5: Live acceptance query (verification-before-completion). After the operator that carries Task 4 is deployed (Task 8), confirm the family has non-empty live series in Prometheus. Use the grafana MCP
query_prometheustool (instant query) or the equivalent HTTP API:
Expected: at least one series with a non-zero value once any agent Task has completed a turn post-deploy. Record the result in the operator MEMORY.md (confirm the dashboard note "populate after A1/A2 deploy" is resolved). If the query is empty after a full scan cycle with known turn-completes, escalate: the emit guard (recorded) or the push/scrape wiring is the fault, not this metric's registration.
- Step 6: Commit.
git add internal/controller/turncallback_test.go
git commit -m "test: assert turn-complete emits operator_task_tokens_total"
Task 6: Observability dashboard - $-spend and cache-hit-ratio panels¶
Files: - Modify: tatara-observability/dashboards/task-delivery.json (add two panels to the panels array; bump version). - Test: a JSON+PromQL validation script run locally (jq assertions + terraform validate). No new file is strictly required; the checks below are run inline.
Interfaces: - Consumes (PromQL, from the wrapper push families now carrying labels): ccw_turn_cost_usd_total{kind,repo,project} and ccw_turn_tokens_total{type,kind,project}. - Produces: two new Grafana panels in the Tatara - Task Delivery dashboard.
Panel layout note: existing panels occupy y=0..26 (gridPos rows 0,10,18). The two new panels go on a new row at y=26. Use id 6 and 7 (existing max id is 5).
- Step 1: Write the failing validation checks. Create
tatara-observability/scripts/check_token_panels.sh(a deterministic guard the deploy step and CI-adjacent review can run):
#!/usr/bin/env sh
# Verifies the token-spend and cache-hit-ratio panels exist and query the
# wrapper cost/token families. Fails non-zero if a panel is missing.
set -eu
DB="dashboards/task-delivery.json"
python3 -c "import json,sys; json.load(open('$DB'))" # valid JSON
# $-spend panel: title present and queries ccw_turn_cost_usd_total by kind,repo.
jq -e '.panels[] | select(.title=="Cost (USD) by Kind and Repo")
| .targets[].expr | select(test("ccw_turn_cost_usd_total"))
| select(test("by ?\\(kind, ?repo\\)"))' "$DB" >/dev/null
# cache-hit-ratio panel: title present and computes cache_read/(cache_read+input).
jq -e '.panels[] | select(.title=="Cache Hit Ratio by Kind")
| .targets[].expr
| select(test("type=\"cache_read\""))
| select(test("cache_read\\|input"))' "$DB" >/dev/null
echo "token panels OK"
Make it executable: chmod +x scripts/check_token_panels.sh.
- Step 2: Run it, expect FAIL.
Expected: non-zero exit at the first jq -e (the panels do not exist yet): jq: error ... / empty output, exit code ¼.
- Step 3: Add the two panels. In
dashboards/task-delivery.json, insert these two objects into thepanelsarray, after the last existing panel (theTurn Rate by Kindpanel, id 5, which closes at line ~518). Add a comma after that panel's closing}and paste:
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"description": "Cumulative Claude spend in USD per Task kind and repo, from the wrapper's ccw_turn_cost_usd_total (result.json total_cost_usd). Populates once wrappers carrying the kind/repo/project labels are deployed. Use to attribute $ spend and A/B the per-kind model tiering.",
"fieldConfig": {
"defaults": {
"color": {"mode": "thresholds"},
"custom": {
"align": "auto",
"cellOptions": {"type": "auto"},
"filterable": true,
"inspect": false
},
"mappings": [],
"unit": "currencyUSD",
"thresholds": {
"mode": "absolute",
"steps": [{"color": "green", "value": null}]
}
},
"overrides": [
{
"matcher": {"id": "byName", "options": "USD"},
"properties": [
{"id": "custom.cellOptions", "value": {"type": "color-background", "mode": "gradient"}},
{"id": "custom.width", "value": 120},
{
"id": "thresholds",
"value": {
"mode": "absolute",
"steps": [
{"color": "green", "value": null},
{"color": "yellow", "value": 1},
{"color": "red", "value": 5}
]
}
}
]
}
]
},
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 26},
"id": 6,
"options": {
"cellHeight": "sm",
"footer": {"countRows": false, "fields": ["USD"], "reducer": ["sum"], "show": true},
"showHeader": true,
"sortBy": [{"desc": true, "displayName": "USD"}]
},
"pluginVersion": "11.0.0",
"targets": [
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"editorMode": "code",
"expr": "sum by (kind, repo) (ccw_turn_cost_usd_total{project=~\"$project\"})",
"format": "table",
"instant": true,
"legendFormat": "__auto",
"refId": "A"
}
],
"title": "Cost (USD) by Kind and Repo",
"transformations": [
{
"id": "organize",
"options": {
"excludeByName": {"Time": true, "__name__": true, "job": true, "pod": true, "run_id": true, "project": true},
"includeByName": {},
"indexByName": {"kind": 0, "repo": 1},
"renameByName": {"Value": "USD"}
}
}
],
"type": "table"
},
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"description": "Prompt-cache hit ratio per Task kind: cache_read / (cache_read + input) over the wrapper ccw_turn_tokens_total families. A ratio near 0 means the stable prefix is being re-billed at full input price every pod (cache-hostile); component 4 (cross-pod cache reclaim) is gated on this rising above 0 for a fresh pod's turn-0.",
"fieldConfig": {
"defaults": {
"color": {"mode": "palette-classic"},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {"legend": false, "tooltip": false, "viz": false},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 2,
"pointSize": 5,
"scaleDistribution": {"type": "linear"},
"showPoints": "auto",
"spanNulls": false,
"stacking": {"group": "A", "mode": "none"},
"thresholdsStyle": {"mode": "off"}
},
"mappings": [],
"min": 0,
"max": 1,
"unit": "percentunit",
"thresholds": {
"mode": "absolute",
"steps": [{"color": "green", "value": null}]
}
},
"overrides": []
},
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 26},
"id": 7,
"options": {
"legend": {"calcs": ["mean", "lastNotNull"], "displayMode": "list", "placement": "bottom", "showLegend": true},
"tooltip": {"mode": "multi", "sort": "desc"}
},
"pluginVersion": "11.0.0",
"targets": [
{
"datasource": {"type": "prometheus", "uid": "prometheus"},
"editorMode": "code",
"expr": "sum by (kind) (rate(ccw_turn_tokens_total{type=\"cache_read\", project=~\"$project\"}[$__rate_interval])) / clamp_min(sum by (kind) (rate(ccw_turn_tokens_total{type=~\"cache_read|input\", project=~\"$project\"}[$__rate_interval])), 0.0001)",
"legendFormat": "{{kind}}",
"refId": "A"
}
],
"title": "Cache Hit Ratio by Kind",
"type": "timeseries"
}
Then bump the dashboard version field (bottom of the file, currently "version": 1) to 2.
- Step 4: Run the checks, expect PASS.
Expected: token panels OK.
- Step 5: Terraform validate (no backend/creds needed).
mise exec -- terraform init -backend=false
mise exec -- terraform fmt -check
mise exec -- terraform validate
Expected: Success! The configuration is valid. The dashboard is loaded via file(...) so an invalid JSON would surface at plan, but validate plus the Step 1 json.load catch a malformed edit. (terraform fmt -check covers only .tf; the JSON edit does not affect it.)
- Step 6: Commit.
git add dashboards/task-delivery.json scripts/check_token_panels.sh
git commit -m "feat: add \$-spend-by-kind/repo and cache-hit-ratio panels to task-delivery dashboard"
Task 7: Deploy the wrapper (tatara-claude-code-wrapper -> main -> tatara-helmfile)¶
Files: - tatara-claude-code-wrapper (merge the Task 1-3 branch to main; CI builds + pushes the image and chart). - tatara-helmfile (MR bumping the wrapper release chart version AND pinned image.tag).
Interfaces: - Produces: a wrapper image whose agent pods emit ccw_turn_tokens_total and ccw_turn_cost_usd_total with kind/repo/project labels.
-
Step 1: Request code review, address critical/high, run pre-commit. Per the working agreement:
superpowers:requesting-code-reviewon the Task 1-3 diff; fix critical/high findings; thenpre-commit run --all-files. -
Step 2: Finish the branch -> merge to wrapper
main. Usesuperpowers:finishing-a-development-branch. On merge, the wrapper CI builds + pushes the image to harbor and packages the chart. Note the merge short SHA. -
Step 3: Bump the wrapper release in tatara-helmfile. Open a tatara-helmfile MR that bumps BOTH the wrapper chart version AND the pinned wrapper
image.tag(per [[tatara-operator-deploy-chart-version-and-image-tag]] and the deploy hard rule - chart-only leaves the old image running). The wrapper image is consumed as the agent pod image viaproject.Spec.Agent.Image; confirm which release / values key carries it in tatara-helmfile and bump the tag there. Review via the MR diff; the pipeline applies on merge. -
Step 4: Verify deploy. After apply, confirm a freshly spawned agent pod runs the new image tag (
kubectl get pod <agent-pod> -o jsonpath='{.spec.containers[0].image}'- READ only, not a deploy path). Do NOTkubectl set image/patchto ship.
Task 8: Deploy the operator (tatara-operator -> main -> tatara-helmfile)¶
Files: - tatara-operator (merge the Task 4-5 branch to main; CI builds + pushes the operator image + tatara-operator/tatara-project charts). - tatara-helmfile (MR bumping the operator release chart version AND pinned image.tag).
Interfaces: - Produces: an operator that sets TATARA_KIND/TATARA_REPO on every agent pod env (the label source the wrapper reads).
-
Step 1: Code review + pre-commit on the Task 4-5 diff (
superpowers:requesting-code-review), fix critical/high,pre-commit run --all-files. -
Step 2: Merge to operator
main. CI builds + pushes the operator image and charts. Note the merge short SHA. -
Step 3: Bump the operator release in tatara-helmfile. MR bumping BOTH the operator chart pin AND the pinned
image.tagin the operator release values (per [[tatara-helmfile-dual-chart-pin-and-cr-adoption-2026-06-22]] the operator deploy bumps TWO chart pins -tatara-operatorandtatara-project; match that pattern). Review via diff; pipeline applies. -
Step 4: Verify deploy + env. Confirm the running operator is the new tag, then confirm a freshly spawned agent pod carries
TATARA_KINDandTATARA_REPO(kubectl get pod <agent-pod> -o jsonpathover.spec.containers[0].env- READ only). Then run the Task 5 Step 5 live Prometheus query to confirmoperator_task_tokens_totalnon-empty.
Task 9: Apply the dashboard + acceptance gate verification¶
Files: - tatara-observability (merge the Task 6 branch to main; the apply GitHub Actions workflow runs terraform apply and provisions the updated dashboard).
Interfaces: - Produces: the live Tatara - Task Delivery dashboard with the two new panels. - Verifies: the component-4 gate metric.
-
Step 1: Open the PR; confirm the sticky terraform plan is clean. The
applyworkflow posts aterraform planon the PR (.github/workflows/apply.yml, triggers ondashboards/**+**.tf). The plan must show an in-place update ofgrafana_dashboard.task_deliveryonly, no destroy. -
Step 2: Merge to
main. The workflow runsterraform apply; the dashboard updates in the GrafanaTatarafolder. -
Step 3: Verify the panels render with live series. Open the
Tatara - Task Deliverydashboard (uidtatara-task-delivery). Confirm: Cost (USD) by Kind and Reposhows non-empty rows once agent pods running the new wrapper (Task 7) have completed turns.-
Cache Hit Ratio by Kindshows a per-kind line. -
Step 4: ACCEPTANCE GATE (component 4 precondition). Run the gate query in Prometheus (grafana MCP
query_prometheus, instant):
Expected: at least one non-empty series, carrying kind, repo, project, model labels, for a fresh pod's turn-0. A visible cache_read series (even value 0) proves the metric is being emitted per-pod with the identity labels; a NON-ZERO value on a fresh pod that follows another same-(repo,model) pod within cache TTL is the stronger signal component 4 needs. Record the observed turn-0 cache_read vs input ratio in the spec's component-4 section (this is the empirical input that decides whether 4b/4c pay off).
- Step 5: Record in MEMORY. Add a dated one-line entry to each touched repo's MEMORY.md (wrapper, operator, observability) noting: token+cost metrics now carry kind/repo/project; dashboard has $-spend + cache-hit-ratio panels; gate metric
ccw_turn_tokens_total{type=cache_read}confirmed live at turn-0 with ratio. Update the spec's phase note that P0 component 6 is shipped.