Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
168aad1
docs(design): draft product metrics (opt-out adoption telemetry) spec
reyortiz3 Sep 8, 2026
42f7654
docs(plan): implementation plan for opt-out product/adoption metrics
reyortiz3 Sep 8, 2026
dbcfe9a
feat(productmetrics): add closed enums, FeatureSnapshot, Config
reyortiz3 Sep 9, 2026
fdd5d60
feat(productmetrics): persist an anonymous random install id
reyortiz3 Sep 9, 2026
54caec5
fix(productmetrics): add missing doc comments on exported enum blocks…
reyortiz3 Sep 9, 2026
e216e46
feat(productmetrics): build the OTLP provider via toolhive-core, bake…
reyortiz3 Sep 9, 2026
6feb598
feat(productmetrics): Recorder.Emit derives bounded session/run/token…
reyortiz3 Sep 9, 2026
b6cb26c
feat(productmetrics): ToolCall counting and the heartbeat ticker
reyortiz3 Sep 9, 2026
2a08386
test(productmetrics): exhaustive guard against unbounded/sensitive at…
reyortiz3 Sep 9, 2026
f5b5df8
fix(productmetrics): guard the privacy test against a vacuous pass
reyortiz3 Sep 9, 2026
5493b6b
feat(permconfig): add the strict telemetry.productMetrics: operator s…
reyortiz3 Sep 9, 2026
3558511
feat(permconfig): resolve the operator-tier telemetry.productMetrics …
reyortiz3 Sep 9, 2026
71eeac1
feat(cliconfig): product-metrics opt-out precedence + ToolCallRecorde…
reyortiz3 Sep 9, 2026
eb3099b
feat(cliconfig): BuildProductMetrics composition helper + disclosure …
reyortiz3 Sep 9, 2026
ba5d680
feat(mecated): wire opt-out product metrics alongside operator telemetry
reyortiz3 Sep 9, 2026
49c8394
feat(mecatui): wire opt-out product metrics alongside operator telemetry
reyortiz3 Sep 9, 2026
c6a5748
feat(mecatequi,mecak8s): wire opt-out product metrics via cliconfig
reyortiz3 Sep 9, 2026
3b26b02
fix(mecak8s): print the product-metrics disclosure notice at startup,…
reyortiz3 Sep 9, 2026
f3945f2
feat(productmetrics): --product-metrics-dry-run audit mode across all…
reyortiz3 Sep 9, 2026
9cbe998
docs: add ADR and user-docs for opt-out product/adoption metrics
reyortiz3 Sep 9, 2026
c54c3b3
Merge remote-tracking branch 'origin/main' into worktree-product-metr…
reyortiz3 Sep 9, 2026
e5ff04e
fix(docs): renumber ADR 0317 to 0319 to resolve a merge collision
reyortiz3 Sep 9, 2026
035f3d2
fix(docs): fix cross-references after the ADR renumbering to 0319
reyortiz3 Sep 9, 2026
e19e8f5
fix(product-metrics): correct shutdown order, headless mode, dedup, D…
reyortiz3 Sep 9, 2026
080398f
refactor(productmetrics): rename the mecatl.adoption.* namespace to m…
reyortiz3 Sep 9, 2026
9bb3cff
fix(productmetrics): drop the per-install id from exported metrics
reyortiz3 Sep 9, 2026
b0454f2
Merge remote-tracking branch 'origin/main' into worktree-product-metr…
reyortiz3 Sep 9, 2026
9ef7786
docs(plan): add follow-on plan for install.id reinstatement + activat…
reyortiz3 Sep 10, 2026
a0cc1f9
feat(engine): add the optional RunAwareToolCallRecorder port capability
reyortiz3 Sep 10, 2026
ba9fa37
feat(productmetrics): reinstate mecatl.install.id after sizing its ca…
reyortiz3 Sep 10, 2026
722c7cf
feat(productmetrics): had_tool_call, tool category/outcome via RunAwa…
reyortiz3 Sep 10, 2026
3b66f4a
fix(productmetrics): complete the builtinToolCategories allowlist
reyortiz3 Sep 10, 2026
5f827f8
feat(productmetrics): run_duration histogram
reyortiz3 Sep 10, 2026
6764f06
feat(productmetrics): add tool_calls_per_run and time_to_first_value
reyortiz3 Sep 10, 2026
148673e
fix(productmetrics): correct time_to_first_value's misleading descrip…
reyortiz3 Sep 10, 2026
177e949
feat(productmetrics): DryRunRecorder mirrors run_duration/tool_calls_…
reyortiz3 Sep 10, 2026
08a5b6c
feat(mecak8s): provision a stable per-release install-id via a Helm C…
reyortiz3 Sep 10, 2026
28da4dd
docs: document had_tool_call, tool category/outcome, and the install.…
reyortiz3 Sep 10, 2026
3526a63
fix(productmetrics): forward RunAwareToolCallRecorder through both co…
reyortiz3 Sep 10, 2026
1d03b14
fix(productmetrics): mecak8s time_to_first_value durability, untracke…
reyortiz3 Sep 10, 2026
bc327e3
Merge remote-tracking branch 'origin/main' into worktree-product-metr…
reyortiz3 Sep 11, 2026
9d34fa6
fix(docs): regenerate the config reference at its post-reorg canonica…
reyortiz3 Sep 11, 2026
9c80c63
Merge remote-tracking branch 'origin/main' into worktree-product-metr…
reyortiz3 Sep 11, 2026
9a4f3b1
fix(docs): renumber ADR 0319 to 0326 after a numbering collision with…
reyortiz3 Sep 11, 2026
ec2132e
Merge remote-tracking branch 'origin/main' into worktree-product-metr…
reyortiz3 Sep 11, 2026
b335e0a
fix(docs): renumber ADR 0326 to 0327 after another numbering collisio…
reyortiz3 Sep 11, 2026
43b34dd
fix(productmetrics): correct the OTLP ingest endpoint hostname
reyortiz3 Sep 11, 2026
4aeb995
Merge remote-tracking branch 'origin/main' into worktree-product-metr…
reyortiz3 Sep 11, 2026
b8edd20
fix(docs): renumber ADR 0327 to 0329 after another numbering collisio…
reyortiz3 Sep 11, 2026
9fd64c8
feat(release): wire the product-metrics ingest key into the real rele…
reyortiz3 Sep 11, 2026
f837b8e
feat(productmetrics): add MECATL_PRODUCT_METRICS as a mecatl-specific…
reyortiz3 Sep 11, 2026
76ff270
fix(productmetrics): address PR #1278 review blockers
reyortiz3 Sep 14, 2026
414cb50
fix(docs): renumber ADR 0329 to 0338 after another collision with main
reyortiz3 Sep 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,33 @@ jobs:
- name: Login to GHCR
run: echo "${{ secrets.GITHUB_TOKEN }}" | ko login ghcr.io -u "${{ github.actor }}" --password-stdin

# Unwraps the plain ingest key from the MECATL_METRICS_KEY secret's
# {"mecatl": "<key>"} JSON blob (stacklok/infra#5604's ExternalSecret
# mirrors the same AWS Secrets Manager property shape) into
# MECATL_METRICS_INGEST_KEY, which .ko.yaml's ldflags read. GitHub
# already masks the raw secret in logs; ::add-mask:: additionally masks
# the unwrapped value, since deriving it doesn't inherit that automatically.
# An absent/empty secret degrades to a disabled pipeline (a bare `=`
# ldflag) rather than failing the release — only a SET-but-malformed
# secret (present, but no non-empty .mecatl property) is a hard error,
# since that is a real misconfiguration worth surfacing immediately.
- name: Extract the product-metrics ingest key
env:
MECATL_METRICS_KEY: ${{ secrets.MECATL_METRICS_KEY }}
run: |
set -euo pipefail
if [ -z "$MECATL_METRICS_KEY" ]; then
echo "::warning::MECATL_METRICS_KEY secret is not set; this build will ship with product metrics disabled"
exit 0
fi
key=$(printf '%s' "$MECATL_METRICS_KEY" | jq -r '.mecatl // empty')
if [ -z "$key" ]; then
echo "::error::MECATL_METRICS_KEY secret is set but has no non-empty .mecatl property"
exit 1
fi
echo "::add-mask::$key"
echo "MECATL_METRICS_INGEST_KEY=$key" >> "$GITHUB_ENV"

# Build multi-arch, push by digest, and have ko generate + push an SPDX
# SBOM next to the image. --bare keeps the repo path clean (no import-path
# suffix), matching `task ko:publish`. We tag both the version and latest.
Expand Down Expand Up @@ -264,6 +291,33 @@ jobs:
- name: Login to GHCR
run: echo "${{ secrets.GITHUB_TOKEN }}" | ko login ghcr.io -u "${{ github.actor }}" --password-stdin

# Unwraps the plain ingest key from the MECATL_METRICS_KEY secret's
# {"mecatl": "<key>"} JSON blob (stacklok/infra#5604's ExternalSecret
# mirrors the same AWS Secrets Manager property shape) into
# MECATL_METRICS_INGEST_KEY, which .ko.yaml's ldflags read. GitHub
# already masks the raw secret in logs; ::add-mask:: additionally masks
# the unwrapped value, since deriving it doesn't inherit that automatically.
# An absent/empty secret degrades to a disabled pipeline (a bare `=`
# ldflag) rather than failing the release — only a SET-but-malformed
# secret (present, but no non-empty .mecatl property) is a hard error,
# since that is a real misconfiguration worth surfacing immediately.
- name: Extract the product-metrics ingest key
env:
MECATL_METRICS_KEY: ${{ secrets.MECATL_METRICS_KEY }}
run: |
set -euo pipefail
if [ -z "$MECATL_METRICS_KEY" ]; then
echo "::warning::MECATL_METRICS_KEY secret is not set; this build will ship with product metrics disabled"
exit 0
fi
key=$(printf '%s' "$MECATL_METRICS_KEY" | jq -r '.mecatl // empty')
if [ -z "$key" ]; then
echo "::error::MECATL_METRICS_KEY secret is set but has no non-empty .mecatl property"
exit 1
fi
echo "::add-mask::$key"
echo "MECATL_METRICS_INGEST_KEY=$key" >> "$GITHUB_ENV"

# Build multi-arch, push by digest, and have ko generate + push an SPDX
# SBOM next to the image. --bare keeps the repo path clean (no import-path
# suffix). We tag both the version and latest. The brood-box agent label
Expand Down Expand Up @@ -397,6 +451,33 @@ jobs:
- name: Login to GHCR
run: echo "${{ secrets.GITHUB_TOKEN }}" | ko login ghcr.io -u "${{ github.actor }}" --password-stdin

# Unwraps the plain ingest key from the MECATL_METRICS_KEY secret's
# {"mecatl": "<key>"} JSON blob (stacklok/infra#5604's ExternalSecret
# mirrors the same AWS Secrets Manager property shape) into
# MECATL_METRICS_INGEST_KEY, which .ko.yaml's ldflags read. GitHub
# already masks the raw secret in logs; ::add-mask:: additionally masks
# the unwrapped value, since deriving it doesn't inherit that automatically.
# An absent/empty secret degrades to a disabled pipeline (a bare `=`
# ldflag) rather than failing the release — only a SET-but-malformed
# secret (present, but no non-empty .mecatl property) is a hard error,
# since that is a real misconfiguration worth surfacing immediately.
- name: Extract the product-metrics ingest key
env:
MECATL_METRICS_KEY: ${{ secrets.MECATL_METRICS_KEY }}
run: |
set -euo pipefail
if [ -z "$MECATL_METRICS_KEY" ]; then
echo "::warning::MECATL_METRICS_KEY secret is not set; this build will ship with product metrics disabled"
exit 0
fi
key=$(printf '%s' "$MECATL_METRICS_KEY" | jq -r '.mecatl // empty')
if [ -z "$key" ]; then
echo "::error::MECATL_METRICS_KEY secret is set but has no non-empty .mecatl property"
exit 1
fi
echo "::add-mask::$key"
echo "MECATL_METRICS_INGEST_KEY=$key" >> "$GITHUB_ENV"

# Build multi-arch, push by digest, and have ko generate + push an SPDX
# SBOM next to the image. --bare keeps the repo path clean (no import-path
# suffix). We tag both the version and latest. The digest is captured for
Expand Down Expand Up @@ -851,6 +932,34 @@ jobs:
repositories: homebrew-tap
permission-contents: write

# Unwraps the plain ingest key from the MECATL_METRICS_KEY secret's
# {"mecatl": "<key>"} JSON blob (stacklok/infra#5604's ExternalSecret
# mirrors the same AWS Secrets Manager property shape) into
# MECATL_METRICS_INGEST_KEY, which .goreleaser.yaml's ldflags read.
# GitHub already masks the raw secret in logs; ::add-mask:: additionally
# masks the unwrapped value, since deriving it doesn't inherit that
# automatically. An absent/empty secret degrades to a disabled pipeline
# (a bare `=` ldflag) rather than failing the release — only a
# SET-but-malformed secret (present, but no non-empty .mecatl property)
# is a hard error, since that is a real misconfiguration worth
# surfacing immediately.
- name: Extract the product-metrics ingest key
env:
MECATL_METRICS_KEY: ${{ secrets.MECATL_METRICS_KEY }}
run: |
set -euo pipefail
if [ -z "$MECATL_METRICS_KEY" ]; then
echo "::warning::MECATL_METRICS_KEY secret is not set; this build will ship with product metrics disabled"
exit 0
fi
key=$(printf '%s' "$MECATL_METRICS_KEY" | jq -r '.mecatl // empty')
if [ -z "$key" ]; then
echo "::error::MECATL_METRICS_KEY secret is set but has no non-empty .mecatl property"
exit 1
fi
echo "::add-mask::$key"
echo "MECATL_METRICS_INGEST_KEY=$key" >> "$GITHUB_ENV"

# `release --clean` wipes dist/, then builds, archives, SBOMs, checksums,
# signs, creates the Release, uploads, and LAST pushes the formula.
#
Expand Down
10 changes: 10 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,18 @@ builds:
# `--version` byte-identical across ko, Task and GoReleaser builds.
# Not `{{ .Tag }}` either: under --snapshot that reports the LAST tag, which
# would lie about what was built.
#
# The product-metrics ingest key (stacklok/infra#5604): {{ .Env.MECATL_METRICS_INGEST_KEY }}
# reads the plain key the release workflow extracts from the MECATL_METRICS_KEY
# secret (a {"mecatl": "<key>"} JSON blob — see .github/workflows/release.yml)
# into that env var BEFORE this step runs. An empty/unset value renders as a
# bare trailing `=`, which is a harmless empty-string ldflag: NewProvider
# refuses to construct on an empty bakedKey, so a run with no key configured
# degrades to the same never-phones-home posture as any local/dev build.
ldflags:
- -s -w
- -X github.com/stacklok/mecatl/internal/buildinfo.BuildID=v{{ .Version }}
- -X github.com/stacklok/mecatl/internal/adapter/productmetrics.bakedKey={{ .Env.MECATL_METRICS_INGEST_KEY }}

- id: mecatui
main: ./cmd/mecatui
Expand All @@ -73,6 +82,7 @@ builds:
ldflags:
- -s -w
- -X github.com/stacklok/mecatl/internal/buildinfo.BuildID=v{{ .Version }}
- -X github.com/stacklok/mecatl/internal/adapter/productmetrics.bakedKey={{ .Env.MECATL_METRICS_INGEST_KEY }}

# NOTE: no `gomod:` block, deliberately. `gomod.proxy` defaults to false; setting
# it true would make GoReleaser fetch github.com/stacklok/mecatl/engine from
Expand Down
10 changes: 10 additions & 0 deletions .ko.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ builds:
ldflags:
- -s -w
- '{{with index .Env "VERSION"}}-X github.com/stacklok/mecatl/internal/buildinfo.BuildID={{.}}{{end}}'
- '{{with index .Env "MECATL_METRICS_INGEST_KEY"}}-X github.com/stacklok/mecatl/internal/adapter/productmetrics.bakedKey={{.}}{{end}}'
# mecak8s (ADR 0048): the storage-free k8s-native agent binary. Same distroless
# base + build flags as mecated — it is a thin peer of mecated that composes
# app.Build with k8s-native defaults (Redis store + k8s lease + drain gate).
Expand All @@ -48,11 +49,19 @@ builds:
ldflags:
- -s -w
- '{{with index .Env "VERSION"}}-X github.com/stacklok/mecatl/internal/buildinfo.BuildID={{.}}{{end}}'
- '{{with index .Env "MECATL_METRICS_INGEST_KEY"}}-X github.com/stacklok/mecatl/internal/adapter/productmetrics.bakedKey={{.}}{{end}}'
# mecatui (issue #302): the optional Bubble Tea TUI binary. It overrides the
# distroless base with the brood-box wolfi base (see baseImageOverrides above —
# brood-box connects over SSH and needs a shell) and ships a kodata/agent.yaml
# brood-box manifest alongside the binary. VERSION, when nonempty, is retained
# verbatim as the explicit linker stamp.
#
# MECATL_METRICS_INGEST_KEY (all three builds above and below): the plain
# product-metrics ingest key the release workflow extracts from the
# MECATL_METRICS_KEY secret's {"mecatl": "<key>"} JSON blob (see
# .github/workflows/release.yml) before invoking `ko build`. Absent/empty
# (any non-release build) omits the ldflag entirely, matching Taskfile.yml's
# local-build posture: NewProvider refuses to construct on an empty bakedKey.
- id: mecatui
main: ./cmd/mecatui
flags:
Expand All @@ -62,3 +71,4 @@ builds:
ldflags:
- -s -w
- '{{with index .Env "VERSION"}}-X github.com/stacklok/mecatl/internal/buildinfo.BuildID={{.}}{{end}}'
- '{{with index .Env "MECATL_METRICS_INGEST_KEY"}}-X github.com/stacklok/mecatl/internal/adapter/productmetrics.bakedKey={{.}}{{end}}'
9 changes: 9 additions & 0 deletions .matlatlignore
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,12 @@ website/CLAUDE.md
# Exclude both the committed reports and their ignored comparison copies.
sdk/typescript/etc/*.api.md
sdk/typescript/.api-extractor-temp/

# docs/superpowers/{plans,specs}/ are the SDD (spec-driven-development) workflow's
# committed historical record of a feature's implementation plan and design spec —
# a point-in-time process artifact analogous to review-*.md/handoff-*.md above, not
# navigational product documentation. They are intentionally standalone (no inbound
# or outbound doc links); the decisions they record land in a proper docs/adr/ entry
# instead, which DOES stay in the corpus. Left in, each new plan/spec adds a fresh
# orphan/unreachable pair that blocks `check --strict`.
docs/superpowers/
3 changes: 2 additions & 1 deletion Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ vars:
else
git describe --tags --match 'v[0-9]*' --always --dirty 2>/dev/null || printf %s dev
fi
BUILD_LDFLAGS: '-X github.com/stacklok/mecatl/internal/buildinfo.BuildID={{.BUILD_ID}}'
MECATL_METRICS_KEY: '{{.MECATL_METRICS_KEY | default ""}}'
BUILD_LDFLAGS: '-X github.com/stacklok/mecatl/internal/buildinfo.BuildID={{.BUILD_ID}} -X github.com/stacklok/mecatl/internal/adapter/productmetrics.bakedKey={{.MECATL_METRICS_KEY}}'
# The GoReleaser BINARY version. Must stay in lockstep with the `version:`
# pinned in .github/workflows/release.yml and .github/workflows/ci.yml.
# .goreleaser.yaml is loaded STRICTLY, so a config field newer than this pin
Expand Down
27 changes: 24 additions & 3 deletions cmd/mecak8s/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,18 @@ type config struct {
otlpMetricsEndpoint string
otlpMetricsProtocol string
otlpShutdownTimeout time.Duration
installationID string

// productMetrics reports anonymous product-adoption metrics to Stacklok.
// OPT-OUT: ON by default. See the --product-metrics flag help text.
productMetrics bool
// productMetricsSet records whether --product-metrics was explicitly passed,
// so ResolveProductMetricsEnabled can let CLI out-rank DO_NOT_TRACK/settings.
productMetricsSet bool
// productMetricsDryRun logs every would-be product-metrics observation
// via diag instead of exporting it over OTLP — an audit mode to verify
// the no-PII claim before trusting --product-metrics for real.
productMetricsDryRun bool
installationID string
}

// stringList is a repeatable string flag.Value, preserving order across
Expand Down Expand Up @@ -475,6 +486,11 @@ func parseFlags(argv []string) (config, error) {
fs.DurationVar(&cfg.otlpShutdownTimeout, "otlp-shutdown-timeout", 5*time.Second, "bound on the telemetry flush at SIGTERM (so a dead collector cannot hang shutdown). 0 disables the bound")
fs.StringVar(&cfg.installationID, "telemetry-installation-id", os.Getenv("MECATL_INSTALLATION_ID"), "stable canonical UUID exported as the optional mecatl.installation.id OTel resource attribute (default: MECATL_INSTALLATION_ID; empty omits it)")

fs.BoolVar(&cfg.productMetrics, "product-metrics", true,
"report anonymous product-adoption metrics to Stacklok (version, OS/arch, enabled features, coarse session/run/tool-call counts — never a prompt, file path, tool name, or model id). ON by default; opt out with --product-metrics=false, MECATL_PRODUCT_METRICS=false, DO_NOT_TRACK=1, or telemetry.productMetrics.enabled: false in settings.yaml")
fs.BoolVar(&cfg.productMetricsDryRun, "product-metrics-dry-run", false,
"print every product-metrics observation to stderr instead of sending it — verify the no-PII claim yourself before enabling --product-metrics for real")

fs.Usage = func() {
_, _ = fmt.Fprint(fs.Output(), "Usage: mecak8s [flags]\n\n")
flaghelp.PrintDefaults(fs.Output(), fs)
Expand Down Expand Up @@ -507,6 +523,8 @@ func parseFlags(argv []string) (config, error) {
cfg.reasoningEffortFlagSet = true
case "subagent-model-router":
cfg.subagentModelRouterSet = true
case "product-metrics":
cfg.productMetricsSet = true
}
markRetentionCLIFlag(&cfg.retentionCLISet, fl.Name)
if fl.Name == "schedule-fire-retention" {
Expand Down Expand Up @@ -730,8 +748,11 @@ func appConfig(cfg config, diag port.Diagnostics, obs observability) app.Config
Diagnostics: diag,
// Observability (issue #343, ADR 0098): OPT-IN. With no --otlp-* flags the
// handles are zero-valued (nil) — the byte-identical no-metrics posture.
Sink: obs.Sink,
ToolCallRecorder: obs.ToolCallRecorder,
// The opt-out product-metrics Sink/ToolCallRecorder are folded in
// alongside (nil-guarded fan-out): both nil reproduces the
// byte-identical no-telemetry posture exactly.
Sink: productMetricsSink(obs),
ToolCallRecorder: productMetricsRecorder(obs),
MetricsRoleScoper: obs.MetricsRoleScoper,
SessionLoadFailureMetricsEmitter: obs.SessionLoadFailureMetricsEmitter,
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/mecak8s/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ func run() error {
// flags this is a no-op (byte-identical default). The flush defer runs BEFORE
// built.Close() (LIFO), so the OTLP flush completes before the service tears
// down on the SIGTERM path.
obs, oerr := buildObservability(ctx, cfg)
obs, oerr := buildObservability(ctx, cfg, diag)
if oerr != nil {
return fmt.Errorf("telemetry: %w", oerr)
}
Expand Down
Loading
Loading