Telemetry
pgfence 0.8.0 and later send an anonymous usage count. If you arrived here from the notice pgfence printed on its first run, this is the full list it pointed at, and nothing was sent during that run.
PGFENCE_TELEMETRY=0 turns it off for one run or one shell. pgfence telemetry disable turns it off for this machine, permanently, and deletes anything queued. Every other mechanism is under Turn it off.
What was sent
At most one request per run, and this is the entire body of it. The event below was captured from a real pgfence 0.8.0 run by setting PGFENCE_TELEMETRY_DEBUG=1, which prints the exact request that would have been made and sends nothing. You can produce the same output on your own machine in one command: see Check it yourself.
{
"v": 1,
"events": [
{
"v": 1,
"eventId": "186c0946-6925-42e3-a1f5-3666b866c8f7",
"installId": "a91fa2bbe8962f84298031450a34f9e7",
"freshInstallId": false,
"command": "analyze",
"version": "0.8.0",
"nodeMajor": 24,
"os": "darwin",
"ci": false,
"ciProvider": "none",
"tty": false,
"format": "sql",
"rulesMode": "default",
"pluginCount": 0,
"fileCountBucket": 1,
"findSafe": 0,
"findLow": 0,
"findMedium": 1,
"findHigh": 0,
"findCritical": 0,
"policyErrors": 1,
"policyWarnings": 3,
"errored": false,
"durationMs": 20,
"ts": 1789590579916
}
]
} That run analyzed one migration file containing a CREATE INDEX. The payload records that one file was read (fileCountBucket: 1), that it was plain SQL, that the stock ruleset found one MEDIUM item plus one policy error and three policy warnings, and that the whole thing took 20ms. It does not record the file's name, the table's name, the index's name, the rule that fired, or a single character of the SQL. tty is false in this capture only because stdout was redirected into a file while capturing it.
The wire envelope is {"v":1,"events":[...]} and a batch carries at most 32 events. There is no body beyond this, no second endpoint and no sidecar process.
Where it goes
POST https://telemetry.pgfence.com/v1/event
content-type: application/json
content-length: <byte length>
user-agent: pgfence/<version> Those three headers are the whole request. No cookies, no authentication header, no custom headers. The response is ignored entirely: the server answers 204 No Content with an empty body, so there is nothing to read. A DNS failure, a refused connection, a TLS error, a corporate proxy rejection or a firewall that silently drops the address all resolve to "not delivered, try again later", never to an error and never to a changed exit code. Interactive runs are never delayed by it at all. A CI run is the one case that can wait, and the bound on that wait is below.
The send path is bounded twice over, because name resolution and connection fail in different ways. Name resolution runs through a cancellable resolver built with its own 250ms budget and a single try, and the connection itself is bounded by a 250ms socket timeout passed as a request option, which arms before the connect starts and therefore bounds the connect phase rather than only an already connected socket. On the CI path, where the send is awaited, a final safety net settles the whole thing at 300ms, so that is the worst case a job can wait. Interactive runs do not wait at all: they write the event to a local queue and a later run delivers it.
What is never sent
None of the following is collected, in any form, hashed or not:
- SQL text of any kind, including fragments, previews, hashes and normalized forms
- File paths, file names, directory names
- Table names, column names, index names, constraint names, schema names, type names
- Repository name, git remote URL, git branch, git commit
- Current working directory
- Hostname, username, user id, home directory path
- Any environment variable's value
- Database URLs, database names, hosts or ports
- Table row counts and table sizes read via
--db-urlor--stats-file - Rule ids, including built-in ones, rule messages, safe rewrite text
- Lock modes, and individual risk levels per finding
- Exit codes
- IP addresses, IP-derived identifiers, geography, country or region
- MAC address, CPU model, CPU count, total memory, OS release string, architecture
- Docker or WSL detection, terminal program, shell, locale, timezone
- Session ids, and any free-form string of any origin
Three of those were genuinely useful and were cut anyway, so the reasoning is worth stating. Rule ids would tell us which checks fire in the wild, but a payload saying "this install fires drop-table weekly" is a description of somebody's schema change posture, and for a tool whose entire pitch is that it reads your migrations, collecting that would be unrecoverable. Any project or repository identifier makes a claim of anonymity falsifiable by pointing at one line of source, and there is no project-level question in scope, so it is excluded permanently. The exit code leaks whether a given install's migrations are dangerous; the one bit we need is errored, which says the tool itself crashed.
How that is enforced
Intent is not a guarantee, so four mechanisms enforce it, and all four are readable in the source:
- A closed vocabulary. Every value in a payload is a number, a boolean, or a string drawn from a list enumerated in one file. The only strings on the wire are
eventId(a uuid this process generated),installId(32 hex this process generated),version(validated against a semver pattern or replaced with the literalunknown), and members of the five enumerations named in the field table below. - Re-projection, not casting. The validator builds a fresh object from the 25 known keys and copies each field only after testing it against its vocabulary, its pattern or its numeric bounds, so an extra key is dropped rather than carried. It runs when the event is built, again when it is read back off the queue, and once more immediately before serialization.
- The import graph.
src/telemetry/imports only Node builtins and its own siblings. It never imports the analyzer, the parser, the rules or the extractors, so it is structurally incapable of reading an analysis result or a file path. The one interface the CLI hands it accepts only numbers, booleans and closed enums, which makes the boundary a compile error rather than a review comment. - The receiver validates independently. The server duplicates the vocabularies rather than importing the client's, and rejects any payload that is not exactly the 25-key shape. An unknown key is a rejection, not a silent drop, because an unknown key is the only way a table name could ever reach the database.
What the receiver stores
The receiver is a Cloudflare Worker backed by a D1 (SQLite) table. It stores one row per event: the payload fields other than v, which is validated and discarded, plus a server-side receive timestamp and the UTC day derived from it. That is 26 columns, and it is the complete column list.
- It never reads
CF-Connecting-IP,X-Forwarded-For,X-Real-IP,True-Client-IPor any other header beyondcontent-length. No client IP address is stored, and there is no column that could hold one. Neither is country, region or any other geographic value. - It never stores a user agent or raw request logs. Worker observability is disabled and no Logpush is configured, so request logs are not retained.
- It stores no free-form text, because it rejects any payload that is not exactly the 25-key closed-vocabulary shape.
- Rows are deleted by a scheduled job 400 days after they are received.
Turn it off
Any one of these is sufficient on its own. They are listed in the order pgfence checks them.
# 1. One run, or one shell. Also accepts false, off, no. Case insensitive.
PGFENCE_TELEMETRY=0 pgfence analyze migrations/*.sql
export PGFENCE_TELEMETRY=0
# 2. Machine wide, cross vendor. Honored for any value except empty, 0 and false.
export DO_NOT_TRACK=1
# 3. This machine, persisted. Also deletes anything already queued.
pgfence telemetry disable The fourth covers one repository, is committed, and applies to everyone on the team. Add this to .pgfence.toml:
telemetry = false or to .pgfence.json:
{ "telemetry": false } pgfence looks for that key in the directory you run it in and in every parent up to the repository root (the directory holding .git), so a telemetry = false committed at the root of a monorepo still applies when a developer, or a CI step, runs pgfence from packages/api. The nearest file that actually sets telemetry wins. This is the only config key resolved that way: every other key is read from the current directory only.
Telemetry is also disabled automatically, with no configuration, when VITEST is set to a non-empty value or NODE_ENV=test, so a test suite that shells out to pgfence never emits anything.
Precedence
First match wins.
| # | Condition | Result |
|---|---|---|
| 1 | PGFENCE_TELEMETRY is set to anything that is not 1, true, on or yes | disabled |
| 2 | DO_NOT_TRACK is set to anything except empty, 0 or false | disabled |
| 3 | VITEST is non-empty, or NODE_ENV=test | disabled |
| 4 | PGFENCE_TELEMETRY is set to an on-value | enabled, skipping rules 5 and 6 |
| 5 | telemetry = false in the project config, in this directory or any parent up to the repository root | disabled |
| 6 | pgfence telemetry disable was run on this machine | disabled |
| 7 | There is no writable config directory (interactive runs only) | disabled |
| 8 | Otherwise | enabled |
Rules 1 through 5 are resolved before pgfence looks at its own state directory, so an opted-out run never causes an install id or a state file to be created, or even read. PGFENCE_TELEMETRY fails closed: a value pgfence does not recognize disables rather than enables, so a typo in an opt-out can never silently turn collection back on. The project config walk fails closed for the same reason: a malformed or unreadable config anywhere up the tree resolves to "opted out".
There is deliberately no --no-telemetry flag. A per-command flag would have to be added to every command and would still miss some of them, while the environment variable, the config key and the subcommand cover every case through one code path.
In CI
Every opt-out works identically in CI. Setting the variable once at the workflow level covers an entire pipeline:
env:
PGFENCE_TELEMETRY: '0' CI runs never write a file. Not the install id, not a queue, nothing: pgfence leaves nothing behind in your runner. It does still read one if it finds one. On an ephemeral runner there is no state file, so the run mints an id for that one run, reports freshInstallId: true and discards it. On a self-hosted runner, or any image with a persistent home directory, the existing id is read and reused and the run reports freshInstallId: false. That difference is not a side effect, it is the signal that separates machinery from humans. A persisted pgfence telemetry disable is honored in both cases, because the state file is read before anything is sent.
No first-run notice is printed in CI. That is not a consequence of stderr lacking a terminal, it is a separate branch: a CI run sends without ever reaching the notice logic, so the first CI run on a fresh runner is counted without a notice having been shown. A build log is not a disclosure, which is why this page and the opt outs above exist. pgfence telemetry status as a step inside the job reports whether collection is on, the install id, the queue depth and the endpoint, though not the event body itself.
What is stored on your machine, and deleting it
One file and one directory per machine.
| Platform | Path |
|---|---|
| macOS and Linux | ~/.config/pgfence/telemetry.json |
Any, when XDG_CONFIG_HOME is set to an absolute path | $XDG_CONFIG_HOME/pgfence/telemetry.json |
| Windows | %APPDATA%\pgfence\telemetry.json |
The queue sits next to it in spool/, holds at most 32 events, and drops anything older than 7 days unsent. A freshly created state file contains exactly two keys, the schema version and the install id. enabled appears only once you have run pgfence telemetry enable or disable, and noticeShownAt only once the first-run notice has actually been displayed.
Deleting the directory is a reset, not an opt-out:
rm -rf ~/.config/pgfence # everything: install id, queue, retry state
pgfence telemetry reset # the same thing, through the CLI Nothing breaks. The next interactive run mints a new random install id, shows the first-run notice again, and records nothing during that run. Anything still queued is gone and is never delivered. Use this to erase what is stored, and one of the four mechanisms above to turn collection off.
To see the current state on your machine, including which layer turned it off:
pgfence telemetry status That command never creates the state file and never emits an event. It resolves every layer the collection path resolves, including your project's .pgfence.toml, so a repository that sets telemetry = false prints "disabled" with a reason: line naming exactly that.
Every field
25 fields. The field count, the names and the order are fixed by the schema version, and every one of them carries its justification here.
| Field | Type | Why it is collected |
|---|---|---|
v | 1 | Payload schema version. Lets the receiver reject or migrate old clients instead of guessing. It is also on the envelope. |
eventId | uuid string | Random per event. The receiver deduplicates on it, which is what makes delivery safe when a run exits with a request in flight. |
installId | 32 lowercase hex | Random per machine, from 16 CSPRNG bytes. Derived from nothing: not the hostname, not the MAC address, not the working directory, not any hash of any of them. Distinct install ids with ci: false is the headcount this whole feature exists to produce. |
freshInstallId | boolean | True when the id was minted during this run rather than read from disk. A population that is almost always true is ephemeral containers, not humans. On an interactive run it is always false, because the run that mints an id is the notice run and that run records nothing. |
command | enum | Which command ran, one of analyze, trace, explain, snapshot, init. Tells us whether humans use anything beyond analyze. |
version | semver or unknown | pgfence's own version. Tells us how fast humans upgrade, which is what makes a headcount actionable. Validated against a semver pattern; anything else becomes the literal unknown. |
nodeMajor | integer 0 to 999 | Node major only, never the full version. Tells us which Node majors we must keep supporting. |
os | enum | Platform, one of darwin, linux, win32, freebsd, openbsd, netbsd, sunos, aix, android, cygwin, other. Tells us whether the Windows code path is worth maintaining. |
ci | boolean | True when a known CI variable is present. This field is the literal subject of the question this feature exists to answer. |
ciProvider | enum | Coarse CI vendor, none when ci is false, otherwise one of github, gitlab, circle, travis, azure, jenkins, buildkite, teamcity, appveyor, codebuild, bitbucket, drone, vercel, netlify, other. Separates one 500-job matrix from 500 different teams, which a boolean cannot. Detected by the presence of a variable; the variable's value is never read beyond present or absent, and never sent. |
tty | boolean | Whether stdout was a terminal. A human at a keyboard, as opposed to a git hook, a pipe or a runner. |
format | enum | Migration format detected across the run, one of sql, typeorm, prisma, knex, drizzle, sequelize, kysely, mixed, none. Tells us which ORM ecosystems actually run pgfence, which decides where extractor work goes. |
rulesMode | enum | Whether the run used the stock ruleset or a customized one, one of default, enable, disable, both. Distinguishes a configured, adopted install from a drive-by run. Never which rules. |
pluginCount | integer 0 to 99 | How many plugins were loaded. A nonzero value means someone invested enough to write custom rules, which is the strongest adoption signal available. Never plugin names or paths. |
fileCountBucket | integer 0 to 5 | Coarse bucket of input files: 0 is 0 files, 1 is 1, 2 is 2 to 5, 3 is 6 to 20, 4 is 21 to 100, 5 is more than 100. A one-file run is a smoke test or a hook; a 40-file run is a real repository. Bucketed rather than exact because an exact count of a private repository's migration directory is a fingerprinting bit with no added value. |
findSafe | integer 0 to 9999 | Findings at effective risk SAFE. |
findLow | integer 0 to 9999 | Findings at effective risk LOW. |
findMedium | integer 0 to 9999 | Findings at effective risk MEDIUM. |
findHigh | integer 0 to 9999 | Findings at effective risk HIGH. |
findCritical | integer 0 to 9999 | Findings at effective risk CRITICAL. Counts only. Effective risk is the size-adjusted one where --db-url or --stats-file escalated it, because that is the value you saw. These five tell us whether humans keep running a tool that finds nothing, which is the difference between installed and used. |
policyErrors | integer 0 to 9999 | Policy violations at severity error. |
policyWarnings | integer 0 to 9999 | Policy violations at severity warning. Split out from the risk counts because policy checks fire on migrations that have no schema findings at all. |
errored | boolean | True when the command's error handler ran. A version whose error rate jumps is a version humans abandon. This is not the CI gate result and not the exit code. |
durationMs | integer, rounded to 10ms, capped at 600000 | Wall clock from handler entry to analysis complete. Separates a real repository run from a trivial one, and catches performance regressions that would drive humans away. |
ts | integer, epoch ms | The client clock when the event was created. Necessary because an interactive event is delivered by a later run, sometimes days later, so the time it arrives is not the time it happened. |
Why it exists
One question, and only one:
How many real humans use pgfence, as opposed to CI runners and registry mirrors?
npm download counts cannot answer it. Most of them are Docker layer caches, CI installs and registry mirrors. GitHub stars cannot answer it either. Registry mirrors are ruled out for free, because a mirror downloads the tarball and never executes the binary, so receiving any event at all already excludes every mirror and every layer cache. What is left is separating humans from CI runners, which is what ci, ciProvider, tty and freshInstallId do.
There is a second reason, and it is specific to this release. Between 0.5.0 and 0.7.0 pgfence exited 0 and analyzed nothing whenever it was launched through a symlink, which is what every npm and pnpm bin entry is, and what npx is. That made a migration safety gate report green while checking zero files, and it stayed that way for four and a half months until a user reported it. A count of runs alongside a count of findings would have made that visible within a week. Fixing the bug is 0.8.0's job; noticing the next one faster is this feature's.
Every field above exists to answer that question and carries its own justification. Several genuinely useful fields were cut because they did not, and because they would have described your schema rather than our headcount. There is no dashboard, no funnel, no session, no cohort and no user-level analytics. The receiver is a table queried with SQL.
Honest limitations
These are the difference between a number and a defensible number.
- Every count is a floor, not a total. An event is dropped rather than allowed to delay the run that produced it. A machine behind a firewall that silently drops the endpoint is never counted, and a CI event whose send fails is simply lost.
- The first interactive run on a machine is never counted. That run shows the notice and deliberately records nothing at all. CI is the exception: a first run on a fresh runner is counted, because it never reaches the notice branch.
- Non-interactive local runs are not counted until an interactive one happens. If your first several runs are inside a git hook or a pipe, you never saw the notice, so nothing is recorded until you run pgfence in a terminal once.
- A machine that runs pgfence once and never comes back is never counted, because interactive events are delivered by a later run.
- CI numbers are run counts, not team counts. Ephemeral containers mint a new install id every run, which is exactly why
freshInstallIdexists. A 20-cell matrix is 20 events from one team. - Anyone can post anything to the endpoint. It is public and unauthenticated by design, so there is no credential in the CLI to steal and none to check. The receiver validates shape and vocabulary, but large sudden changes should be treated as suspect.
Check it yourself
Nothing on this page has to be taken on trust. Run any pgfence command with PGFENCE_TELEMETRY_DEBUG=1 and it prints the exact request it would have made, to stderr, and sends nothing:
PGFENCE_TELEMETRY_DEBUG=1 pgfence analyze migrations/*.sql Two lines appear on stderr: the destination, then the entire request body.
[pgfence telemetry] POST https://telemetry.pgfence.com/v1/event
{"v":1,"events":[{"v":1,"eventId":"186c0946-6925-42e3-a1f5-3666b866c8f7", ... }]} That is the same event shown at the top of this page, in the form it actually goes out in. The variable sends nothing, does not drain the queue and does not advance the retry timer, so you can run it as many times in a row as you like and see the same thing against your own migrations. It is a verification aid and not an opt-out: the run still records its own event locally, exactly as it would have without the variable, so use one of the mechanisms under Turn it off to actually disable collection.
If you want to watch the whole path instead of reading it, point the CLI at a server you control. PGFENCE_TELEMETRY_ENDPOINT accepts https: for any host and http: only for 127.0.0.1, localhost and [::1], which exists so the send path can be tested locally and cannot be used to downgrade the real endpoint. A malformed override disables sending rather than guessing.
Editors
The pgfence language server never emits telemetry. Not once, not on a schema, not on a document change. No file under src/lsp/ so much as mentions it, and the pgfence-lsp binary points straight at the server and never loads the CLI entry point, so the editor path is covered by the absence of an import rather than by a runtime flag that could be flipped back. The VS Code extension is a thin client over that server and adds nothing of its own.
Read the source
The whole implementation is five files and imports nothing but Node builtins. A skeptical reviewer can read the first one in about a minute and see the entire payload contract.
| File | What it is |
|---|---|
| src/telemetry/types.ts | The payload, the five closed vocabularies, and the validator. Imports nothing at all. |
| src/telemetry/env.ts | Opt-out resolution, CI detection, state directory resolution. |
| src/telemetry/store.ts | The state file and the queue. |
| src/telemetry/post.ts | The send path, and the only place node:https is loaded. |
| src/telemetry/session.ts | Orchestration and the pgfence telemetry subcommand. |
| telemetry-worker/ | The receiver: validation, schema, and the queries it answers. |
Three invariants hold throughout: telemetry never changes an exit code, never writes to stdout outside the pgfence telemetry subcommand, and never throws out of any function the CLI calls.
The longer reference, with the full precedence rules, the retry schedule and the receiver's queries, is docs/telemetry.md in the repository. If you find something on this page that the code does not do, that is a bug and we want the report: github.com/flvmnt/pgfence/issues.