How BrassCoders Designed Opt-In Usage Telemetry

A security scanner that phones home has a trust problem. How BrassCoders 2.1.0 made telemetry off by default, readable before it sends, and easy to refuse.

BrassCoders Team · · 12 min read
privacyoss-coreengineering

A security scanner that wants to phone home starts from a deficit. You installed it to find leaks. The moment it asks for a network connection of its own, every claim it makes about your data gets a second look, and it should. BrassCoders, the scanner that catches the bugs AI coders miss, added opt-in usage telemetry in release 2.1.0 on 2026-09-29. This post documents how that telemetry was designed to survive the second look. Off by default. One small event with a fixed field list. A local copy of every byte before it leaves, and a gateway that rejects anything outside the allowlist.

Why a Security Scanner Phoning Home Is a Trust Problem

BrassCoders treats its own telemetry as a finding-grade risk: a tool whose job is to flag hardcoded secrets and PII in your code has no credible way to ask for usage data unless the request can be read in full, refused in one command, and checked from outside the process. The design target was boring telemetry, the kind you can audit in under a minute.

This post extends a series on what leaves your machine. What BrassCoders Sends to Its Servers documents the Paid-plan enrichment payload. What Leaves Your Machine When an LLM Reviews Your Code compares that with API-based review. Can I Run BrassCoders Offline? covers the --offline switch. Telemetry is the one outbound path that exists for our benefit rather than yours, which is why it gets the strictest treatment of the four.

The reason to have it at all is blunt. PyPI download counts say how many times a package was fetched. They say nothing about whether a scan ever ran, on which OS, or how many findings it produced. Without some signal, the roadmap is guesswork. With a badly designed signal, the product loses the trust that made it worth installing. The design below tries to get the first without paying the second.

Off by Default and Asked Once

BrassCoders telemetry is off until you say yes, and the CLI asks exactly once, on an interactive terminal after your first successful scan, with No as the default answer. The prompt is suppressed in CI and wherever there is no TTY. It is also suppressed under --offline or BRASS_OFFLINE and whenever BRASS_TELEMETRY is already set.

Here is the prompt, verbatim from the current source:

📊 Help improve BrassCoders? (optional, anonymous usage stats)

   Starting with your next scan, BrassCoders would send one small event per free
   (unlicensed) scan to Copper Sun: the count of findings by type and severity, whether --fast or
   --dev was used, your BrassCoders version, your OS name, and a random install ID
   (a UUID stored in ~/.brass/telemetry that identifies this install, not you).

   Never sent: source code, file paths or names, emails, license keys, stack
   traces, or anything from .brass/*.yaml. Nothing is ever sent with --offline.
   Every event is also written to ~/.brass/telemetry-debug.log so you can check.

   Change your mind any time: brasscoders telemetry on|off|status|reset

   Enable anonymous usage stats? [y/N]:

Every gate is a hard no. The prompt runs after the point where the scan’s own event would have fired, so the scan you just watched is never sent, and the copy can say “starting with your next scan” and mean it. Pressing Enter, hitting Ctrl-C, or closing stdin all persist off, and the CLI prints OK — telemetry stays off. We won't ask again. A Ctrl-C at the prompt is swallowed on purpose: a completed scan with YAML already on disk must not turn into exit code 130 because of a question.

The TTY check is the primary guard. Jenkins and Azure Pipelines don’t set CI, but they have no terminal either, so a pipeline can never block on input(). One more detail: when the gate says not to ask, the CLI persists nothing. You are never marked as asked when you weren’t. The question waits for your first interactive scan.

Exactly What One Event Contains

A BrassCoders telemetry event has ten fields and weighs roughly 350 to 450 bytes, and none of them carries text drawn from your project. The fields are counts and flags about the scan, plus a version string, an OS family, a random install ID, and a timestamp.

Here is a representative event, formatted the way the debug log stores it (keys sorted, no whitespace). The counts are invented for the example. The shape is exact.

{"brass_version":"2.1.0","dev_mode":false,"event":"scan","fast":false,"finding_types":{"code_quality":41,"performance":3,"security":12},"install_id":"3f9c2b7e4a1d4c08b6e5d2a9f0c1b7e4","platform":"darwin","severity_counts":{"high":4,"low":31,"medium":21},"timestamp_ms":1759276800000,"total_findings":56}

platform is the OS family only: darwin, linux, windows, or other. No version number and no hostname. A Windows (WSL2) install reports linux, because WSL2 is a Linux kernel and that is what Python’s platform.system() sees.

finding_types and severity_counts carry only the keys that occurred in this scan. Type keys come from a fixed set of seven (security, privacy, code_quality, todo, architecture, performance, analysis_error) and severity keys from a fixed set of five. The values are integers. Nothing else can appear under either key.

install_id is a UUID4 minted once and stored in ~/.brass/telemetry. It identifies the install, not you, and it is deliberately stable across opt-out: turning telemetry off and back on counts as one install rather than two. brasscoders telemetry reset mints a new one, after which earlier events can no longer be linked to your install.

timestamp_ms is your machine’s clock. The server stamps its own receive time on every event regardless, so a wrong client clock can’t place an event in the past or the future.

What Is Never Sent

BrassCoders telemetry never sends source code, file paths or filenames, finding titles or text, the project name or signature, email addresses, license keys, environment variables, stack traces, or anything from .brass/*.yaml, and the gateway does not store your IP address.

The guarantee is structural rather than a redaction step. The event is built from the ranked findings list by counting: the code reads two attributes per finding (its type and its severity) and increments a counter. There is no code path from a finding’s text or path into the payload. Redaction removes sensitive values from data that was about to be sent. Here the sensitive data was never collected.

Error reporting is a deliberate omission. Crash telemetry is useful, and a stack trace from a Python scanner routinely includes file paths from the project under scan and sometimes a line of source. BrassCoders has no crash or error reporting. If it breaks, you tell us. It doesn’t tell on you.

The Local Mirror You Can Diff

Every BrassCoders telemetry event is appended to ~/.brass/telemetry-debug.log as one JSON line before the HTTPS request is attempted, so the log records exactly the bytes the CLI tried to send, whether or not the network cooperated.

The ordering matters. A log written after a successful send tells you what got through. A log written before the attempt tells you what the client intended, including events that died on a dead network. For an audit, the second is the one you want. Compare it against a packet capture of the brasscoders process and any discrepancy is a bug worth reporting to brass@coppersuncreative.com.

Practical details. On macOS and Linux the file is created with mode 0600 inside a 0700 directory. It rotates once at 256 KiB (to telemetry-debug.log.1), which at 350 to 450 bytes per event is roughly 600 scans. Under --offline no event is built, so the log is untouched. To watch it live, run tail -f ~/.brass/telemetry-debug.log in one terminal and a scan in another.

The Controls

BrassCoders exposes four telemetry subcommands (on, off, status, and reset) plus two environment overrides, and --offline wins over all of them, including a saved opt-in.

brasscoders telemetry status   # on/off, the reason, install ID, file paths
brasscoders telemetry off      # persist off in ~/.brass/telemetry
brasscoders telemetry on       # persist on; same install ID as before
brasscoders telemetry reset    # mint a new install ID; consent unchanged

status prints the deciding reason as one of four strings: BRASS_OFFLINE env, BRASS_TELEMETRY env, consent file, or default (never opted in). If an environment variable is overriding your saved choice, it says so and names the variable. The resolution order is fixed and short:

  1. BRASS_OFFLINE set to a truthy value: off, no matter what else says.
  2. BRASS_TELEMETRY=off forces off; BRASS_TELEMETRY=on forces on.
  3. The consent= line in ~/.brass/telemetry.
  4. Default: off.

off keeps the install ID, so a later on is the same install. Only reset changes the ID. For a CI runner that might inherit a developer’s home directory, BRASS_TELEMETRY=off or --offline is the belt; the missing TTY is the suspenders.

What the Gateway Does With It

The BrassCoders gateway validates each telemetry event against a strict allowlist schema that rejects any unknown field with HTTP 400, caps the request body at 2 KiB, throttles by IP and by install ID, and forwards only the validated fields to a store with 90-day retention.

The schema is a Zod object declared with .strict(). By Zod’s own documentation, a plain z.object() strips unrecognized keys from the parsed result, while the strict form throws when unknown keys are found; the Zod API reference shows both behaviors side by side. That distinction is the entire server-side privacy guarantee. With a stripping schema, a CLI bug that added a new field would pass through until someone noticed it in the store. With a strict schema, the same bug produces a 400 on the first request and the event is dropped. A future mistake fails loud and fails closed.

The body cap is 2048 bytes against a real event of 350 to 450. The throttle is a fixed 60-second window of 60 events per IP (enough for a busy office NAT) and 30 per install ID. Both are abuse ceilings for an unauthenticated route; neither is a product quota. Throttle keys are hashed, and the IP is salted with a server-side secret before hashing, so the 60-second key can’t be reversed to an address. If the throttle’s Redis backend errors, the gateway drops the event and still answers 204. Abuse protection must not fail open. Telemetry can afford to lose an event.

What reaches the store is the parsed, validated data and never the request object: no IP, no user agent, no headers. Events are kept for 90 days. The route’s own logging is exception-only, and it never logs the body, the IP, or the install ID.

Free Scans Only

BrassCoders telemetry covers free, unlicensed scans only; a scan that ran with a Paid license sends no event, because Paid-plan usage is already visible through the licensed gateway path and collecting it a second way would duplicate the same signal.

One point of candor. The 2.1.0 release notes and the field table on the privacy page describe the free-scan-only rule, and the prompt text quoted above carries it. The code gate that enforces the rule at the call site landed on the main branch the day after the 2.1.0 tag, alongside that prompt wording, and ships in the next release. If you are on 2.1.0 exactly with an active license, the debug log will show you whether an event was built. That is the point of the log.

The Homebrew Precedent

Homebrew’s analytics are opt-out: the package manager shows a notice before analytics are enabled and sends events unless you run brew analytics off. BrassCoders inverted that default to opt-in and otherwise borrowed the shape: a fixed field list, no IP field in the payload, a published retention period, and a way to see what was sent.

Homebrew’s Anonymous Analytics page is the model worth reading. It states that “Homebrew displays a notice before analytics are enabled so a user can opt out before sending an event” and that the payload “does not contain a user identifier or an IP-address field.” On retention it is exact: “Homebrew retains analytics events in InfluxDB for 365 days.” Users disable it with brew analytics off or export HOMEBREW_NO_ANALYTICS=1.

Two differences deserve plain statement. Homebrew sends no identifier at all. BrassCoders sends a random per-install UUID, because distinct-install counts are the one number download statistics can’t provide, and it offers reset to rotate that UUID at will. BrassCoders also keeps events for 90 days against Homebrew’s 365. The default is the big one. Homebrew has the installed base to absorb whatever opt-out rate it gets. A new security scanner can’t absorb a single user who feels tricked.

What This Telemetry Cannot Tell Us

BrassCoders telemetry cannot measure its own decline rate, because a no at the prompt writes one line to a local file and sends no request, and it cannot distinguish “nobody ran a scan” from “nobody opted in,” because both produce zero events.

These are costs of the design and not oversights. A scanner that reported declines would be sending data from people who had just said no. So the dashboard on our side shows opted-in installs, their OS mix, and their finding counts. It shows no trace of the installs that declined or were never asked. If PyPI reports a thousand downloads and the store holds ten events, we cannot tell 990 people who never ran it from 990 who ran it and said no. We accept that. The alternative is telemetry that reports on the people who refused telemetry.


Install with pipx install brasscoders and run brasscoders scan /path/to/project. When the prompt appears, read it. Say no if you like; the CLI won’t ask again. If you say yes, cat ~/.brass/telemetry-debug.log after your next scan and check it against this post. The full field table and retention terms live on the privacy page. The Paid plan at $12/month adds the AI-powered enrichment pass and is not covered by telemetry at all.

Frequently Asked Questions

Is BrassCoders telemetry on by default?

No. BrassCoders usage telemetry stays off until you answer yes to a one-time prompt or run brasscoders telemetry on. The prompt appears once, on an interactive terminal, after your first successful scan. It never appears in CI, without a TTY, under --offline or BRASS_OFFLINE, or when BRASS_TELEMETRY is already set. Pressing Enter or Ctrl-C counts as no, and the CLI does not ask again.

What does one BrassCoders telemetry event contain?

Ten fields, roughly 350 to 450 bytes: the event name (always scan), the CLI version, the OS family (darwin, linux, windows, or other), a random per-install UUID, a client timestamp, the total finding count, finding counts by type, finding counts by severity, and two booleans for whether --fast and --dev were used. No source code, file paths, finding text, emails, license keys, or stack traces.

How can I see exactly what BrassCoders sent?

Read ~/.brass/telemetry-debug.log. Every event is appended there as one JSON line before the HTTPS request is attempted, so the log records the exact bytes the CLI tried to send whether or not the network cooperated. The file rotates once at 256 KiB. Under --offline no event is built, so the log stays untouched.

How do I turn BrassCoders telemetry off?

Run brasscoders telemetry off. The choice persists in ~/.brass/telemetry and keeps your install ID, so a later on is the same install. For a single run or in CI, set BRASS_TELEMETRY=off, or pass --offline, which disables every outbound path and wins over a saved opt-in. brasscoders telemetry status prints the current state and the reason for it.

Does BrassCoders store my IP address with telemetry events?

No. The BrassCoders gateway uses the client IP only as a salted SHA-256 hash for a 60-second abuse throttle, after which the Redis key expires. Only the schema-validated event fields are forwarded to the event store, which keeps them for 90 days. The IP, user agent, and request headers are never in scope for storage.

Does telemetry apply to BrassCoders Paid scans?

No, by design. BrassCoders telemetry covers free, unlicensed scans only. Paid-plan usage is already visible through the licensed gateway path, so a scan that ran with a license sends no telemetry event. The 2.1.0 release documents this; the code gate that enforces it at the call site landed the day after the 2.1.0 tag and ships in the next release.