Skip to content

feat: add opt-out product/adoption metrics over OTLP - #1278

Open
reyortiz3 wants to merge 51 commits into
mainfrom
worktree-product-metrics-otel
Open

feat: add opt-out product/adoption metrics over OTLP#1278
reyortiz3 wants to merge 51 commits into
mainfrom
worktree-product-metrics-otel

Conversation

@reyortiz3

@reyortiz3 reyortiz3 commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a fully independent, opt-out-by-default product/adoption-metrics pipeline (internal/adapter/productmetrics) reporting bounded, privacy-safe counters to a dedicated public OTLP collector (stacklok/infra#5604), wired into all four mecatl binaries (mecated, mecatui, mecatequi, mecak8s).

  • Architecturally isolated from mecatl's existing operator-facing telemetry (internal/adapter/telemetry): separate MeterProvider, separate destination, zero shared state — combined only via a fan-out at the composition edge.
  • Privacy-bounded catalog (mecatl.product.*): an anonymous per-install identifier, install heartbeat, closed feature-flag/provider-family/deployment-mode enums, coarse session/run/tool-call/token counts, run duration, tool-calls-per-run, and a one-time-per-install time-to-first-value sample — never a session id, model id, raw tool/MCP-server name, or free text. Enforced by a reflect-based guard test on Recorder's public API.
  • Opt-out precedence: --product-metrics=false > MECATL_PRODUCT_METRICS env var (a mecatl-specific override, wins in either direction over DO_NOT_TRACK) > DO_NOT_TRACK env var (the cross-tool convention) > operator-tier-only telemetry.productMetrics.enabled: false in settings.yaml > default enabled.
  • Self-verification: --product-metrics-dry-run logs every would-be observation via port.Diagnostics instead of exporting, so an operator can confirm the "no PII" claim empirically.
  • Build-time gated: the OTLP ingest key is baked in only at release build time; every local/dev/CI build has no key, so NewProvider refuses to construct — a non-release build can never phone home. Wired end-to-end into the real release pipeline: MECATL_METRICS_KEY (a {"mecatl": "<key>"} GitHub Actions secret, mirroring the AWS Secrets Manager property stacklok/infra#5604 reads) is unwrapped and masked in .github/workflows/release.yml, then read by .goreleaser.yaml (Homebrew/archive builds) and .ko.yaml (container images) into a -X .../productmetrics.bakedKey=... ldflag for every binary that ships this pipeline (mecated, mecatui, mecak8s). An absent/malformed secret degrades to the same never-phones-home posture as a local build, not a failed release. Verified end-to-end offline: a snapshot goreleaser build and a local ko build --local both actually bake a test key into the resulting binary.
  • Endpoint: https://mecatl.metrics.stacklok.com/v1/metrics (a mecatl. subdomain — confirmed against stacklok/infra#5604's actual listener), header x-mecatl-metrics-key.
  • mecak8s-specific: since it runs storage-free with no PVC (ADR 0048), its Helm chart provisions the per-install identifier via a Kubernetes ConfigMap (generated once, reused across every helm upgrade) rather than the local-file mechanism the other three binaries use. This coexists with the separate, independently-shipped operator-facing mecatl.installation.id (a different concern — an operator's own OTLP telemetry, not this pipeline) — both env vars and both ConfigMaps render unconditionally in the same Deployment.
  • Full design rationale in docs/adr/0329-product-metrics.md; user-facing docs in user-docs/building/what-you-get/observability.md.

Implemented via superpowers:subagent-driven-development across two rounds of task-by-task work, each with an isolated implementer + independent task review, culminating in a final whole-branch review each round with no unresolved Critical/Important findings.

Metrics catalog (mecatl.product.*)

Resource attributes (set once per process, not per-metric labels): service.name, service.version, mecatl.install.id (a per-install random UUID — see "Install identifier" below), mecatl.binary (mecated/mecatui/mecatequi/mecak8s).

Heartbeat (on start, then every ~24h for long-running processes; single fire for mecatequi):

Instrument Kind Attributes
mecatl.product.heartbeat counter none — install/liveness signal
mecatl.product.feature_enabled counter featurememory/guardrails/mcp/scheduling
mecatl.product.provider_configured counter familyanthropic/openai/openrouter/other (never a model id/alias)
mecatl.product.deployment_mode counter modeinteractive/headless/k8s

Coarse usage & activation (derived from the event/tool-call tap):

Instrument Kind Attributes Fires on
mecatl.product.sessions_started counter none session start
mecatl.product.runs_completed counter stop (bounded stop reason), had_tool_call (true/false: at least one successful tool call in the run) run completion
mecatl.product.tool_calls counter category (a built-in tool's own name, mcp for any MCP-server tool via the structural mcp__ prefix, or other — never a raw MCP server/tool name), outcome (success/error) every tool call
mecatl.product.tokens counter kindinput/output/cache_read/cache_write/reasoning run completion
mecatl.product.subagent_used counter none first Subagent use in a run
mecatl.product.team_used counter none first Team use in a run
mecatl.product.run_duration histogram (s) none run completion, when this process observed the run's start
mecatl.product.tool_calls_per_run histogram (count) none every run completion this process tracked (a run's EvSessionInit was observed)
mecatl.product.time_to_first_value histogram (s) none once per install, ever — the first run that both made a successful tool call and ended cleanly

Nothing here is free text, a session/run/model identifier, a raw tool/MCP-server name, a file path, a prompt, or an output — every label value is drawn from a closed enum or a maintained allowlist with a safe other fallback. Full rationale, including the tool-category allowlist's design constraints, in docs/adr/0329-product-metrics.md.

Install identifier

An anonymous per-install random UUID (mecatl.install.id) is attached as a resource attribute. This was deliberately left out initially over Prometheus-remote-write cardinality concerns, then reintroduced after sizing the actual cost against the real backend (Amazon Managed Service for Prometheus): roughly $1,930/month at 100,000 installs under a worst-case assumption (every instrument, 24/7 uptime), roughly $650/month under a realistic one — well within what the adoption signal it enables (activation rate, time-to-first-value, weekly retention, exact unique-install counts) is worth. mecated/mecatui/mecatequi persist it in a local state file; mecak8s (storage-free, no PVC) gets it from a Helm-provisioned ConfigMap instead, so pod restarts don't churn the id. time_to_first_value is not recorded on mecak8s — it needs a durable once-ever marker this pipeline doesn't yet have there, so it stays silent rather than emit a misleading per-restart sample.

Engine change

Adds port.RunAwareToolCallRecorder (engine/port/log.go), an optional, purely-additive extension to ToolCallRecorder letting a recorder also learn which run a tool call belongs to — needed to correlate ToolCall (session-scoped) with the event stream's RunID for had_tool_call/tool_calls_per_run/time_to_first_value. Mirrors the existing HookApprovalLearner precedent exactly; no existing ToolCallRecorder implementer is affected. Added (minor) per engine/COMPATIBILITY.md. The optional capability is forwarded end-to-end through every composition-layer decorator between productmetrics.Recorder and Deps.ToolCallRecorder (the tee fan-out and the session-mutation-capability guard) — verified with a dedicated regression test in each, since an optional-capability interface silently stops propagating the moment any decorator in the chain forwards only the base interface.

Product questions this answers

Product framed the initial ask around four themes — activation, time to value, reliability, retention — as six concrete questions. Here's what this catalog can answer directly, and what needs query-time composition on top of it:

Question Answered by How
Activation rate — did a new install do something meaningful? runs_completed{had_tool_call} + mecatl.install.id Group by install id, compute the share of installs whose first (or any) run has had_tool_call="true" within a window. Not a single-metric readout — a query over the two.
Time-to-first-value mecatl.product.time_to_first_value Direct. A one-time-per-install histogram of the wall-clock gap from this install's first-seen moment to its first run that both made a successful tool call and ended cleanly (stop=end_turn).
Weekly retention mecatl.install.id + mecatl.product.heartbeat/sessions_started Not a dedicated metric. Retention is a cohort query: distinct install ids seen in week N that are also seen in week N+1, using heartbeat/session-start presence as the "seen" signal.
Meaningful-run success rate runs_completed{stop, had_tool_call} Direct-ish: "meaningful" = had_tool_call="true", "success" = stop="end_turn". The rate is count(had_tool_call=true AND stop=end_turn) / count(had_tool_call=true) — one query over one instrument's two attributes.
Tool-execution success rate mecatl.product.tool_calls{outcome} Direct. count(outcome=success) / count(outcome=success or error), optionally sliced by the bounded category attribute to see which tool families are less reliable.
Weekly active users/instances (WAU) mecatl.install.id + mecatl.product.heartbeat/sessions_started Not a dedicated metric. Count of distinct install ids with at least one heartbeat or session-start in a 7-day window — this is exactly the query the reinstated per-install identifier exists to make possible; without it, only aggregate volume was measurable, never a unique-instance count.

The two "not a dedicated metric" rows are the honest cost of keeping the catalog flat and bounded rather than pre-baking retention/WAU cohort logic into the pipeline itself — they're answerable with the data shipped here, just at query time in the observability backend, not as a single mecatl-emitted counter.

Test plan

  • task lint — 0 issues, every module
  • task test — fully green (root, engine, internal/adapter/productmetrics/internal/cliconfig/internal/adapter/server, all provider submodules, GOWORK=off standalone hygiene proofs)
  • go test ./deploy/helm/mecak8s/... — full chart suite green with the real helm binary, including the new install-id ConfigMap test and the coexisting mecatl.installation.id tests
  • task build — all 5 binaries build, byte-identical local-dev posture confirmed (empty baked key)
  • task docs / task site:build — 0 broken links/anchors/orphans/unreachable
  • Manual: bin/mecated serve --help-all shows --product-metrics (default true) with accurate help text
  • Release-key wiring: goreleaser check, task lint:actions (actionlint + embedded shellcheck), and an offline snapshot goreleaser build / local ko build --local all confirm the ingest key actually reaches the compiled binary
  • Reviewer: confirm the mecatl.metrics.stacklok.com collector (stacklok/infra#5604) is live/ready to receive traffic — confirmed live

🤖 Generated with Claude Code

reyortiz3 and others added 27 commits September 8, 2026 18:33
Design a separate, opt-out-by-default OTLP pipeline reporting bounded
adoption/usage counters to Stacklok's public metrics collector, fully
isolated from the existing operator-facing telemetry pipeline.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
15-task TDD plan implementing the approved design: an independent
internal/adapter/productmetrics adapter (toolhive-core-backed OTLP
provider, bounded EventSink/ToolCallRecorder, heartbeat, install id,
dry-run audit mode), operator-tier-only settings.yaml gate, opt-out
precedence via cliconfig, and wiring into all four binaries.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Task 1 of the product-metrics-otel plan: create the internal/adapter/
productmetrics package skeleton with closed-set enums (Feature,
ProviderFamily, DeploymentMode, Binary), the FeatureSnapshot value
object + its enabled() method, and Config.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds LoadOrCreateInstallID (+ LoadOrCreateInstallIDDefault), reading/
writing a bare v4 UUID under $XDG_STATE_HOME/mecatl/telemetry-id via
xdgconfig.UserStateDir, regenerating on a missing/corrupt file and
failing closed when no state dir can be resolved. Promotes
github.com/google/uuid from indirect to direct in go.mod (task tidy).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… (revive)

Adds a one-line doc comment above each of the four const blocks
(Feature, ProviderFamily, DeploymentMode, Binary) in config.go to
satisfy revive's exported-const-needs-comment rule. No names or
values changed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…d-key gated

Provider/NewProvider wraps toolhive-core's CompositeProvider to export
mecatl adoption metrics to the fixed metrics.stacklok.com OTLP/HTTP
ingest, gated by a build-time-baked ingest key (BUILD_LDFLAGS in
Taskfile.yml). An empty baked key (every local/dev/CI build) disables
the pipeline entirely, so NewProvider always errors. Also wires
providers.WithInsecure off the endpoint's scheme, since toolhive-core
strips the scheme and defaults to TLS regardless -- required for the
package's own httptest-backed test to actually reach a plaintext
listener.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… counts

Recorder implements port.EventSink, constructing all ten adoption
instruments up front (heartbeat/feature/provider/mode counters are
wired for a later task's Heartbeat method; tool_calls for its ToolCall
method). Emit reads only ev.Type, ev.Result.Stop, and ev.Result.Usage —
never ev.Result.Text or ev.Result.Error.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds Recorder.ToolCall (port.ToolCallRecorder) and Recorder.Heartbeat +
RunHeartbeat, restoring the port.ToolCallRecorder compile-time assertion
in metrics.go now that ToolCall exists.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…tributes

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…chema

Adds the operator-tier telemetry: subtree (telemetry.productMetrics.enabled)
to permconfig.Config, parsed strictly (unknown keys error) mirroring
OpenRouterSection. Also wires the top-level Config.UnmarshalYAML dispatch
table (permconfig.go) so the new key is actually decoded, and registers the
subtree in internal/configgen's BuildModel/tier-pin guard so the
generated settings reference and skeleton stay in sync with the schema
(both required for task lint && task test to stay green; this task adds
schema only, resolution/wiring for the actual telemetry gate is a later task).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…opt-out

Add the resolver half of the product-metrics config schema landed in Task 7:
an operatorTelemetry field, captureTelemetry capture method mirroring
captureOpenRouter, and the OperatorProductMetricsEnabled() accessor. Wired
into both loadUserRules capture sites (CLI + user-global) and a
project-tier WARN-ignore block in loadProjectRules, so a project repo can
never flip a user's own product-metrics opt-out in either direction.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…r fan-out

Adds ProductMetricsPrecedence + ResolveProductMetricsEnabled (CLI flag >
DO_NOT_TRACK env var > operator settings.yaml > default-enabled) and
TeeToolCallRecorder, the ToolCallRecorder twin of telemetry.NewSink's
EventSink fan-out, in a new internal/cliconfig/productmetrics_config.go.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…notice

Adds the composition-level pipeline builder (install id -> provider ->
recorder -> heartbeat goroutine) that a cmd main threads into its
EventSink/ToolCallRecorder fan-out, plus the one-time opt-out disclosure
notice text. Disabled returns zero handles with a no-op Shutdown; enabled
with no baked ingest key surfaces the error rather than disabling silently.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds --product-metrics (default true) and threads the already-built
productmetrics/cliconfig pipeline into mecated's EventSink/ToolCallRecorder
fan-out. Resolves the effective enabled value via a throwaway permconfig
resolver (mirroring mecated's own mcplogin.go precedent, since app.Build's
internal resolver is never exposed back to run()) and registers the flag
in helpmeta.go's completeness table.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds --product-metrics (default true, embedded-server only) and threads the
already-built productmetrics/cliconfig pipeline into mecatui's embedded
server's EventSink/ToolCallRecorder fan-out.

Unlike mecated, mecatui wires composition.Sink/ToolCallRecorder BEFORE
embed.Start rather than at run() itself, because the perf-observability
feature (independently OFF by default) can also overwrite those same fields
inside embed.Start's setupPerf/wirePerfSinks. wirePerfSinks now captures
whatever cfg.Sink/cfg.ToolCallRecorder already held before reassigning them,
so the product-metrics tap survives untouched when perf is off (the default)
and gets folded in via cliconfig.TeeToolCallRecorder alongside the perf
metrics when perf is also enabled.

The effective enabled value resolves through a throwaway permconfig resolver
built with mecatui's own hardcoded embedded discovery posture
(Conventional/ImportClaude: true), mirroring mecated's precedent since
app.Build's own resolver is never exposed back to main.go. The disclosure
notice goes through diag.Log (never stderr, which would corrupt the Bubble
Tea alt-screen) and registers the flag in helpmeta.go's completeness table.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Extend buildObservability in both binaries to take a port.Diagnostics
and build the opt-out product-metrics pipeline (cliconfig.BuildProductMetrics)
alongside the existing opt-in OTLP telemetry, fanning both into the engine's
EventSink/ToolCallRecorder (nil-guarded, since TeeToolCallRecorder is not
nil-safe over an all-nil input). Adds --product-metrics (default true) to
both flag surfaces, flushes the pipeline on exit/SIGTERM, and prints the
first-run disclosure notice to stderr.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… not shutdown

mecak8s is a long-running daemon, so printing the first-run disclosure
only in flushTelemetry left it invisible until process exit (potentially
days/weeks later) and never printed at all on a SIGKILL/OOM-kill. Move
the print into buildObservability, right after BuildProductMetrics
returns, mirroring cmd/mecated/main.go's setupProductMetrics shape.
mecatequi is unaffected (single-shot process; setup and flush are
seconds apart there).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… four binaries

Adds DryRunRecorder (port.EventSink + port.ToolCallRecorder) which logs
every would-be product-metrics observation via port.Diagnostics instead
of exporting it over OTLP, reading only the same bounded fields the real
Recorder reads (event type, stop reason, token counts by kind,
feature/provider/mode enums) -- never a tool name, session id, or free
text. BuildProductMetrics gains a dryRun bool (after enabled) and a
port.Diagnostics parameter; when dryRun is true it short-circuits before
any install-id read/write or real OTLP provider construction, firing one
representative Heartbeat instead of a full ticker. Wires
--product-metrics-dry-run (default false) into mecated, mecatui,
mecatequi, and mecak8s, threading each binary's existing diag into its
BuildProductMetrics call site.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds ADR 0317 recording the shipped product-metrics design (isolation
architecture, the exact mecatl.adoption.* catalog, opt-out precedence,
operator-tier-only settings gate, privacy guard test, dry-run mode, and
why opt-out is defensible here), extends the existing observability.md
user-docs page with a "Product / adoption metrics (opt-out)" section,
and fills in ProductMetricsDisclosureNotice's doc-link placeholder.

Also excludes docs/superpowers/ (the SDD workflow's committed plan/spec
artifacts) from the matlatl docs corpus, matching its existing
transient-artifact exclusions — required for `task docs --strict` to
pass now that this feature's plan/spec files are the only content
under that path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
main gained ADR 0317 (canonical-shell-command-tool) and 0318 while this
branch was open; renumber the product-metrics ADR to the next free slot
and fix every cross-reference (docs/adr/README.md, the disclosure notice
string, user-docs). Caught by CI's TestRealADRNumberUniqueness.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Update docs/adr/README.md's index entry, the disclosure-notice string,
and the user-docs link to point at the renumbered ADR file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…O_NOT_TRACK, dry-run heartbeat

Address review findings from PR #1278:

- Fix the mecated shutdown-defer order so cancelHeartbeat() runs before
  pm.Shutdown() (defers are LIFO), matching mecatui's correct order.
- Derive productMetricsSnapshot's Mode from cfg.headless instead of
  hardcoding ModeInteractive, so headless mecated deployments report
  correctly.
- Dedup subagent_used/team_used to one count per run (keyed by the
  loop-stamped RunID, cleared on EvResult), matching their documented
  "at least once per run" semantics instead of counting every
  EvSubagentStart/EvTeamStart.
- Treat only a truthy DO_NOT_TRACK value as an opt-out ("0"/"false" no
  longer disable telemetry), per the consoledonottrack.com convention.
- Run the dry-run pipeline's one-shot Heartbeat call synchronously
  instead of in an unawaited goroutine, so it can't be lost on a
  short-lived process like mecatequi.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ecatl.product.*

mecatl.usage.* was considered and rejected: it would read ambiguously
next to resource-usage metrics (CPU/RSS) in the same dashboard.
mecatl.product.* is unambiguous and matches the feature's own name
throughout the ADR/docs. Kept flat (no further category level under
product.*) — ten instruments doesn't earn the added structure yet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This pipeline's destination is a Prometheus-remote-write backend
(stacklok/infra#5604), where every OTel resource attribute becomes a
permanent label on EVERY instrument's time series. Attaching a random
per-install UUID there would multiply active series by
(installs x instrument count) with no bound as adoption grows -- an
unbounded-cardinality cost for a precision (exact unique-install
counts) the design never actually required.

Remove Config.InstallID and the mecatl.install.id resource attribute
entirely. installid.go's local file still exists to detect first-run
for the disclosure notice, but its UUID value is now discarded rather
than threaded to NewProvider. Unique-install counts become an
approximation from heartbeat volume/cadence instead of an exact count.

Also fixes two stale doc references caught while updating: the
DO_NOT_TRACK docs said "any non-empty value" (already contradicted by
an earlier fix that excludes "0"/"false"), and consoledonottrack.com
has been domain-squatted since this was written -- both now point at
the live donottrack.sh convention text.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
jhrozek added a commit that referenced this pull request Sep 9, 2026
…ht ADRs

ADR 0319 was independently claimed by two other open PRs (#1238, #1278).
Renumber this ADR to 0323, the next free number after main's highest
(0322), to avoid a duplicate-number collision on merge.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
reyortiz3 and others added 2 commits September 10, 2026 17:54
…ion metrics

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds port.RunAwareToolCallRecorder, an OPTIONAL ToolCallRecorder extension
that additionally receives the calling run's RunID. The dispatcher's one
ToolCallRecorder call site (engine/agent/dispatch.go execute) type-asserts
for it and prefers ToolCallForRun when implemented, falling back to the
plain ToolCall otherwise — purely additive, no existing ToolCallRecorder
implementer is affected. Mirrors the HookApprovalLearner precedent in
engine/port/hookrunner.go.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
reyortiz3 and others added 17 commits September 10, 2026 18:08
…rdinality cost

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…reToolCallRecorder

Consume the new optional port.RunAwareToolCallRecorder so this adapter can
correlate a tool call to the run that made it, and derive two new bounded
facts from that correlation:

- runs_completed gains had_tool_call ("true"/"false"): whether the run made
  at least one SUCCESSFUL tool call. An errored-only run is false - the
  product definition is "this run took an action", not "attempted one".
- tool_calls gains category + outcome. outcome is {success,error}; category
  is the closed-set projection in toolCategory.

The prior subagent/team per-run dedup (runFamiliesUsed/usedFamilies/
firstInRun/clearRun) is unified into ONE mutex-guarded per-run record,
perRunTracker/perRunState, which the later run_duration and
tool_calls_per_run work extends. Its lock discipline is the point: every
method does its map lookup AND its field mutation as a single critical
section and no *perRunState escapes a locked region, because Emit (per
event) and ToolCallForRun (per tool call) run concurrently on a fan-out
run. finish() returns a value copy so the had_tool_call read at EvResult is
race-free, and clears the entry so the map stays bounded to live runs.

DEVIATION from the plan's stated approach, flagged for review: the plan
specified deriving category from the structural mcp__ prefix check ALONE,
"never a maintained allowlist". That design emits any non-mcp__ name
verbatim, which fails this package's existing privacy-guard test (it drives
ToolCall with Name: "secret-tool-name-marker" and asserts no marker reaches
an attribute value) and would put an unbounded label on the counter - a
catalog is not only built-ins plus MCP tools, since an agent def, a learned
skill, or a future extension seam can register a name derived from operator
or model input. toolCategory therefore keeps the mcp__ prefix bucket AND
gates verbatim names through builtinToolCategories, falling back to the
single literal "other". A new built-in showing up as "other" is the
visible, harmless prompt to add a line; a leak is impossible either way.

DryRunRecorder is brought into lockstep in the same commit (part of the
plan's Task 6): an audit path that understates what the real Recorder sends
defeats the purpose it exists for. Its remaining Task 6 work (the later
histograms) is untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds the six team-coordination tools (SendMessage, AddTask, ClaimTask,
CompleteTask, ListTasks, RecordFinding) and InspectSession, which were
missing and collapsing into "other" -- losing exactly the
delegation-adoption signal this metrics catalog cares about. Removes
BashSystemTemp, a permission-gate name that is never a dispatched
ToolCall.Name and so could never match.

Fixes review findings from Task 3 of the activation-extension plan.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Add mecatl.product.run_duration, a wall-clock histogram from a run's
EvSessionInit to its EvResult. perRunState gains a startedAt field
stamped by a new markStarted tracker method (same lock discipline as
markFamilyUsed/markToolCall), and recordResult records the duration
only when startedAt was actually observed, so a run whose
EvSessionInit this Recorder never saw does not record a bogus
duration.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Publish the per-run tool-call tally that perRunState already tracked as
mecatl.product.tool_calls_per_run, an Int64 histogram recorded at every
EvResult (a tool-less run contributes an honest 0, so the distribution
keeps its left tail).

Add mecatl.product.time_to_first_value: a Float64 histogram recorded at
most ONCE per install, ever, measuring the duration from this install's
first-seen moment to its first run that both made a successful tool call
and ended on StopEndTurn. The once-ever contract has two halves: an
in-memory firstValueTracker whose test-and-claim is one critical section
(a fan-out deployment must not record two samples), and a local marker
file under XDG_STATE_HOME mirroring installid.go's injected-filesystem
shape, written at the instant the qualifying run is observed rather than
at startup -- a process that never has a qualifying run must not burn the
install's one sample.

FirstValueRecorded is therefore a pure READ used at startup, distinct
from LoadOrCreateFirstValueMarker (the write half used as the record
callback). Recording is off unless armed: NewRecorder's signature is
unchanged and an unarmed Recorder never touches the instrument, so every
existing caller and test keeps its exact prior posture.

BuildProductMetrics arms it with time.Now() as firstSeenAt -- deliberate
approximation for a coarse onboarding signal, sound because the marker
means the metric can only fire on an install that has not yet had a
qualifying run. A marker-read failure degrades to "track it anyway"
rather than failing the pipeline.

The no-PII guard in bounded_test.go now walks histogram data points too,
and fails hard on an aggregation shape it does not recognise, so a future
instrument cannot silently fall outside it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…tion

The instrument description claimed it approximates "install first-seen
moment", but the value actually recorded is the START OF THE PROCESS
that reaches the qualifying run -- the LAST startup with no marker, not
the first. A user who runs mecatl repeatedly over several days and
only reaches a qualifying run on day three would see minutes, not
three days, which is not what the old description promised a
dashboard consumer.

Fixes a review finding from Task 5 of the activation-extension plan.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…per_run/time_to_first_value

DryRunRecorder already logged category/outcome/had_tool_call (from an
earlier merged task); it did not yet mirror Recorder's run_duration,
tool_calls_per_run, or time_to_first_value fields on runs_completed.

Emit's EvSessionInit case now marks the run started via the existing
perRunTracker, and emitResult logs tool_calls_per_run and (when this
process observed the run's start) run_duration_seconds alongside the
existing fields.

time_to_first_value diverges from Recorder by design: dry-run stays
stateless (no XDG_STATE_HOME reads/writes), so instead of the real
once-ever, persisted-marker sample it logs a would-be
time_to_first_value observation on every qualifying run (StopEndTurn +
at least one successful tool call) — more verbose than the real
pipeline, but the honest choice for a "show what could be sent" audit
surface. Documented on DryRunRecorder's doc comment.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…onfigMap

mecak8s runs storage-free with no PVC (ADR 0048), so it cannot use the
local-file product-metrics install-id mechanism the other three binaries
share: every pod restart would mint a fresh, never-reused id — the
worst-case cardinality pattern for that pipeline.

The chart now provisions ONE id per release into a
`<fullname>-install-id` ConfigMap, using the standard `lookup` idiom so a
`helm upgrade` reuses the existing value verbatim and only a first
install mints a new one, and mounts it into the container via
`MECATL_PRODUCT_METRICS_INSTALL_ID`.

`BuildProductMetrics` gains an `installIDOverride` parameter: non-empty
skips `LoadOrCreateInstallIDDefault` entirely (and never reports
FirstRun — the chart, not this process, owns the id's lifecycle). The
other three call sites pass "" and are behaviourally unchanged.

Because the env var is unconditional, the Deployment now always renders
an `env:` block; the chart tests' env-shape assertions run through a new
`appEnv` helper so they keep asserting exactly what they did before, and
the one render-equality test masks the per-render uuidv4.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…id reinstatement

Updates ADR 0319's catalog table and adds two new sections covering
this round's changes: the RunAwareToolCallRecorder engine port
extension (why it exists, how the had_tool_call/category/outcome
derivation works, and why category needs a maintained allowlist
alongside the mcp__ prefix rule) and mecak8s's Helm ConfigMap
install-id mechanism (why the local-file approach can't work there,
how the lookup/uuidv4 idiom keeps it stable across upgrades).

Updates the startup disclosure notice and the user-facing
observability doc to honestly reflect that an anonymous per-install
identifier is now collected, reversing the prior "no ... identifier"
framing now that its reintroduction has been sized and accepted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…mposition decorators

The final whole-branch review of the had_tool_call/tool_calls_per_run/
time_to_first_value work found that neither decorator between
productmetrics.Recorder and Deps.ToolCallRecorder implemented the new
port.RunAwareToolCallRecorder interface: cliconfig.multiToolCallRecorder
(TeeToolCallRecorder's fan-out) and internal/adapter/server's
capabilityToolCallRecorder (SessionMutationCapability.GuardToolCallRecorder)
both had only ToolCall. Since dispatch.go's type-assertion is on the
composed Deps.ToolCallRecorder value itself, it always failed and fell
back to plain ToolCall -- silently making had_tool_call,
tool_calls_per_run, and time_to_first_value inert in every real binary,
despite every per-task test and gate passing (nothing exercised the
recorder the composition layer actually installs).

Both decorators now implement ToolCallForRun, forwarding to each
wrapped recorder's richer form when available and falling back to
ToolCall otherwise -- capabilityToolCallRecorder under the same
capability.allows(id) gate ToolCall already uses. Adds a regression
test in each package asserting the composed/guarded value still
satisfies port.RunAwareToolCallRecorder, driven the same way
dispatch.go drives it (type-assert, then call).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…d-run bias, doc drift

Three more findings from the final whole-branch review:

- mecak8s has no durable local marker for time_to_first_value's
  once-ever contract (the same storage-free problem, ADR 0048,
  install-id solves via a Helm ConfigMap -- time_to_first_value never
  got the same treatment). armFirstValueTracking now no-ops entirely
  when installIDOverride is non-empty, so mecak8s simply doesn't
  report this metric rather than silently emitting one sample per pod
  restart under a "once per install, ever" label.

- perRunTracker.finish now returns (perRunState, tracked bool):
  tracked is false for an empty RunID or a RunID this Recorder never
  saw an EvSessionInit/tool call for (e.g. RetryFailedStep's
  RunRequest{}, which mints no RunID). recordResult and DryRunRecorder
  now skip tool_calls_per_run entirely for an untracked run instead of
  recording a fabricated 0 -- a 0 there was indistinguishable from a
  genuine zero-tool-call run and would have biased the distribution
  downward for a retried run that may have made many real tool calls.

- ADR 0319 doc drift: a stale "deliberately never exported" line two
  sections above the section that reverses it, a privacy paragraph
  that both said "never a tool name" three rows below a table showing
  tool_calls' category attribute IS one (bounded, allowlisted, but a
  name) and cited the wrong file for the closed set, and a stale
  "a later task publishes" comment in toolcall.go now that task has
  landed. The bounded_test.go guard's vacuity floor is tightened from
  a "< 10" floor to an exact 13 (NewRecorder's real instrument count),
  and its driving code now exercises run_duration too (it previously
  never observed an EvSessionInit for its run-aware scenario).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ics-otel

# Conflicts:
#	Taskfile.yml
#	cmd/mecak8s/flags.go
#	deploy/helm/mecak8s/chart_test.go
#	deploy/helm/mecak8s/templates/deployment.yaml
#	docs/configuration-reference.md
…l location

My merge-conflict resolution for docs/configuration-reference.md
mistakenly regenerated the OLD full-content target -- ADR 0321's docs
reorg had already landed on main, replacing that file with a short
redirect stub and repointing the generator (per Taskfile.yml's
docs:configref task) at user-docs/reference/configuration.md instead.
Restores the stub and regenerates the correct target, which also picks
up this branch's telemetry.productMetrics config documentation for
the first time.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… main

origin/main independently used ADR number 0319 for an unrelated topic
(release-archives-and-homebrew-tap) since this branch's last merge.
Renumbers this branch's ADR to 0326 (the next free number) and fixes
its three cross-references (the ADR index, the disclosure notice
string, and the user-facing observability doc). The historical plan
document under docs/superpowers/plans/ is left referencing the old
number, matching this repo's convention that historical planning
artifacts are not retroactively updated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…n with main

main claimed 0326 for an unrelated ADR (lazy-toolhive-metadata-refresh)
between this branch's last fetch and this push. Renumbers to 0327 (the
next free number after fetching main's current tip) and fixes the
same three cross-references as the prior renumbering.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@reyortiz3
reyortiz3 marked this pull request as ready for review September 11, 2026 17:18
reyortiz3 and others added 3 commits September 11, 2026 15:25
The hardcoded endpoint was https://metrics.stacklok.com/v1/metrics, but
the actual listener stacklok/infra#5604 provisions is
https://mecatl.metrics.stacklok.com/v1/metrics (a mecatl. subdomain).
As written, every export would have silently failed against the wrong
host regardless of a correctly baked key. Fixes the endpoint var and
its three references in the ADR.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…n with main

main claimed 0327 for an unrelated ADR (self-repository-action-refs)
in the window between the last fetch and this push. Renumbers to 0329
(main's own tip was already at 0328) and fixes the same three
cross-references.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Sep 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
mecatl-website Ready Ready Preview Sep 11, 2026 7:34pm UTC

Request Review

reyortiz3 and others added 2 commits September 11, 2026 16:24
…ase pipeline

Adds an "Extract the product-metrics ingest key" step to all four
release jobs that build a real mecated/mecatui/mecak8s artifact (the
three ko image jobs plus the GoReleaser archive job): it unwraps the
plain key from the MECATL_METRICS_KEY secret's {"mecatl": "<key>"}
JSON shape (mirroring the AWS Secrets Manager property
stacklok/infra#5604 reads) into MECATL_METRICS_INGEST_KEY, masks it,
and exports it via GITHUB_ENV for the build step that follows.

.goreleaser.yaml and .ko.yaml now read that env var into a
-X .../productmetrics.bakedKey=... ldflag alongside the existing
BuildID stamp, in every build block that ships a productmetrics-enabled
binary (mecated, mecatui, mecak8s). An absent/empty secret degrades to
an empty ldflag (product metrics stay disabled, matching every
local/dev/CI build's existing posture) rather than failing the release;
a SET-but-malformed secret (no non-empty .mecatl property) is a hard
error, since that indicates a real misconfiguration.

Verified end-to-end offline: a snapshot goreleaser build and a local
ko build --local both actually bake a test key into the resulting
binary (confirmed via strings). goreleaser check, task lint:actions
(actionlint + embedded shellcheck), and YAML parsing all clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… opt-out override

Adds MECATL_PRODUCT_METRICS as a second env-var opt-out signal,
checked BEFORE the generic DO_NOT_TRACK convention so it can win in
either direction: a user can opt mecatl back in despite an ambient
DO_NOT_TRACK=1 set for other tools, or opt mecatl out specifically
without touching DO_NOT_TRACK. Named to match the existing
--product-metrics flag and telemetry.productMetrics.enabled settings
key exactly, deliberately not *_DO_NOT_TRACK or *_TELEMETRY --
internal/adapter/telemetry already means something else in this
codebase (the unrelated, opt-in operator OTLP/Prometheus pipeline), so
a same-flavored name here would misleadingly suggest this variable
also touches that pipeline.

Accepts any strconv.ParseBool-recognized spelling; unset, empty, or
unparseable falls through to the next precedence tier (DO_NOT_TRACK,
then the operator settings.yaml, then default-enabled) rather than
being treated as a false positive.

Updates the five surfaces that document or announce the opt-out
mechanisms: the ADR's precedence list, the startup disclosure notice,
each of the four binaries' --product-metrics flag help text, and the
user-facing observability doc.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant