Privacy and personal data (152-FZ)
Gotcha is a self-hosted platform: you run it on your own infrastructure and control all of the data. Under Russia’s Federal Law No. 152-FZ “On Personal Data” (and comparable regimes), that makes you the personal-data operator (controller), not the Gotcha developers. This page explains what personal data the system processes, what is already enabled to minimize it, and which obligations stay with you.
This is technical guidance, not legal advice. Consult a qualified lawyer for a full assessment of your obligations.
A dev secret key means no encryption at rest
GOTCHA_SECRET_KEY is what encrypts SSO client secrets, channel tokens, and
monitor HTTP header values (e.g. an Authorization bearer token) in
PostgreSQL. While it is left at the built-in development default, encryption is
not enabled at all — those secrets sit in the database as plain text.
Encrypting them with a key that is published in the source would be theatre, so
the product does not pretend otherwise.
Two consequences worth knowing:
- Setting a real key encrypts what’s already stored, too: on every boot, the app re-wraps all readable secrets (SSO client secrets, delivery channel tokens, monitor headers) under the current key, including values that are still sitting as legacy plain text. The exception is values that are already unreadable (for example, sealed under a lost key): the backfill leaves those alone and logs them — they still need to be re-entered manually through the UI.
- On a non-local
GOTCHA_BASE_URLthe app refuses to start with the default key in theweb,all,ingest, anduptimemodes (everywhere exceptprobe), so this normally only affects local instances — unless the refusal was explicitly overridden.
Rotating the encryption key (GOTCHA_SECRET_KEY)
Before you start: there’s no downtime to announce. Sessions are stored
separately from at-rest encryption — the session token doesn’t depend on
GOTCHA_SECRET_KEY — so rotating the key doesn’t log anyone out, and active
sessions survive it untouched. That’s worth stating plainly because the
natural expectation from swapping an encryption key runs the other way. The
one thing rotation can catch is a user who happens to be mid-way through an
OAuth redirect at the moment of restart: the signed cookie for that step is
sealed under a separate subkey that changes along with GOTCHA_SECRET_KEY,
and it won’t verify after restart — that login just needs to be retried. Any
ordinary restart landing in that same narrow window has the same effect, so
for users, rotation is no different from a routine restart.
Encrypted values carry the fingerprint of the key that sealed them
(enc:v2:<key-id>:...), so rotation is a managed, reversible procedure
rather than a one-shot secret swap:
Rotate only once the whole fleet is running the version that has this
procedure. During a rolling deploy, an old binary sitting next to the new
one doesn’t understand the enc:v2:... format: it hands it back as-is as if
it were the live secret, and writes fresh values in the old format,
bypassing the backfill that already ran. Start rotation only once the
rollout has reached 100% of the fleet.
Set
GOTCHA_SECRET_KEY_PREV=<old key>and the newGOTCHA_SECRET_KEY=<new key>, then restart the instance. Right on boot, a line with the key-ring IDs is logged — it reports the ring’s composition, not the outcome of re-encryption:INFO secretbox keyring ready current_key_id=<new-id> previous_key_id=<old-id> rotation_in_progress=truecurrent_key_id(<new-id>) is the new key — that’s the one you’ll need in the next step.previous_key_id, together withrotation_in_progress=true, is only here to confirm the ring actually came up with two keys. Right after, everything readable (SSO client secrets, channel tokens, monitor headers) is re-encrypted under the new key; the actual outcome is one line per store:INFO org: rewrap secrets backfill complete updated=<N> unreadable=<N> INFO alert: rewrap secrets backfill complete updated=<N> unreadable=<N> INFO uptime: rewrap secrets done updated=<N> unreadable_skipped=<N>Note the field name differs: uptime uses
unreadable_skipped, the other two useunreadable.A nonzero
unreadable/unreadable_skippedisn’t an emergency — that’s what a secret that lost both keys looks like — but it means some secrets need re-entering through the UI before you removePREV.Separately from that counter, the same run may log WARN-level lines. Three of them mean a SINGLE row was left behind — one text per store:
WARN alert: rewrap channel secret: update failed WARN org: rewrap sso secret: update failed WARN uptime: rewrap secrets: update monitor failedThis is a secret that was readable and should have moved to the new key, but the targeted
UPDATEfor it didn’t land because of a SQL failure. That line is counted in neitherunreadablenorunreadable_skipped— the counters look clean while the secret is still sitting on the old key. (There’s a second reason anUPDATEcan miss — a concurrent write moved the row between the read and the update, a CAS miss — but that one isn’t logged at any level at all, and the only way to tell it apart from “already fully migrated” is the query below.)A whole pass over a store can fail too —
rewrap org sso secrets failed,rewrap alert channel secrets failed,rewrap monitor header secrets failed. That’s worse than a single row: the store wasn’t processed at all, and its summary INFO line won’t be in the log — you’ll be one line short of the three in step 1.If you see any of these lines, don’t move to step 3 yet — just restart the instance again (step 1 again): the backfill is idempotent, so the next pass picks up what was left behind.
Then check the database directly to make sure nothing is left that isn’t on the new key yet (
<new-id>comes from thesecretbox keyring readyline in step 1). This check is version-neutral: it also catches the very oldest envelope format,enc:<b64>with no key ID at all, which is what an existing installation’s entire database looks like before its very first rotation (a check forLIKE 'enc:v2:<old-id>:%'would never have matched that):SELECT count(*) FROM alert_channels WHERE secret LIKE 'enc:%' AND secret NOT LIKE 'enc:v2:<new-id>:%'; SELECT count(*) FROM org_sso WHERE client_secret LIKE 'enc:%' AND client_secret NOT LIKE 'enc:v2:<new-id>:%'; SELECT count(*) FROM monitors WHERE kind = 'http' AND config::text ~ 'enc:(?!v2:<new-id>:)';The third query uses a negative-lookahead regex (PostgreSQL supports
(?!...)for~by default).monitors.configis JSON that can hold several header values in one row; a plainNOT LIKE 'enc:v2:<new-id>:%', like the two queries above use, wouldn’t work here — a row with one header already on the new key and another still on the old one would read as falsely clean, because that comparison matches the whole string, not each value separately. The query above catches exactly that row: it fires if the row contains even oneenc:occurrence not followed byv2:<new-id>:.All three should return
0.Remove
GOTCHA_SECRET_KEY_PREVand restart again. The key ring is back to a single key.
Rotation is reversible as long as the old key isn’t lost: swapping the two
keys (GOTCHA_SECRET_KEY=<old>, GOTCHA_SECRET_KEY_PREV=<new>) rolls the
instance back the same way. Fear of irreversibility shouldn’t be the reason
rotation gets postponed — it doesn’t need to be.
What personal data is processed
| Category | Where it lives | Examples |
|---|---|---|
| Gotcha account holders | PostgreSQL: users, user_identities | email at registration, email and subject from an SSO/OAuth provider |
| End users of observed applications | ClickHouse: events, transactions, metric_points, logs | user_id, user_ip, user_email, user.*/enduser.* attributes in tags (transactions), attributes (metric_points), log_attributes (logs) |
| Free text with possible personal data | ClickHouse: events.message/exception_value/stacktrace/contexts, spans.description/data, profile_samples.stack, logs.body, tags | anything an SDK or developer placed into an error message, transaction name, SQL/URL, log message text |
| Notification delivery addresses | PostgreSQL: org_invites.email, channel configuration | invitee emails, email/Telegram/webhook recipient addresses |
| Host telemetry | PostgreSQL: hosts (name, agent_version); ClickHouse: metric_points (system.* metrics) | host.name — often an internal server name rather than personal data, but sometimes includes an owner’s login or domain; the installed gotcha-agent version; uptime and system metrics (CPU/memory/disk/network) carry no personal data on their own |
Sessions (sessions) store only a token hash and user_id — no IP, no user agent — so minimization is enforced at the schema level.
What is enabled by default
- IP and email scrubbing also matches FIELD NAMES. With
GOTCHA_SCRUB_IP/GOTCHA_SCRUB_EMAILon, the value of any field whose name (case- and separator-insensitive) containsemail— or one ofuser_ip,ip_address,client_address,net_peer_ip,net_sock_peer_addr,network_peer_address,network_local_address,client_ip,x_forwarded_for,x_real_ip,remote_addr,cf_connecting_ip,Forwarded— is redacted, in tags, OTLP attributes,log_attributes,span.data, headers and query strings. In practice acustomer_emailtag becomes[scrubbed]even though it is not inGOTCHA_SCRUB_DENY_KEYS. UseGOTCHA_SCRUB_KEEP_KEYSto get a specific name back — it is checked BEFORE the email and IP rules, so it overrides them for an exactly matching name. The match is exact:GOTCHA_SCRUB_KEEP_KEYS=emailonly unmasks a field whose normalized name is exactlyemail; it affects neithercustomer_emailnorevents.user_email(that one is nulled by the separateGOTCHA_SCRUB_EMAILrule). - IP and email scrubbing on ingest.
GOTCHA_SCRUB_IP=trueandGOTCHA_SCRUB_EMAIL=trueby default:events.user_ipandevents.user_emailare nulled before they reach ClickHouse. Denylisted keys (GOTCHA_SCRUB_DENY_KEYS) are stripped from tags, contexts, headers, query strings and request bodies. Matching is fail-closed: a name is redacted when it contains a denylist word, sox_api_key,clientSecretandmytokenare all caught — and so areauthor(containsauth) andtokenizer(containstoken). Under-redacting leaks personal data; over-redacting costs a debugging field and is reversible withGOTCHA_SCRUB_KEEP_KEYS. - Retention. TTL is enforced and configurable: events and more via
GOTCHA_EVENT_RETENTION_DAYS, with spans, metrics, profiles, and logs (GOTCHA_LOG_RETENTION_DAYS) having their own settings (see Configuration). Data is deleted from ClickHouse automatically once its retention expires (a retention of0disables deletion for that class). - Retention covers summary records too — each by the retention of its own telemetry. Error and performance issues (title, culprit) are deleted once their last event is older than
GOTCHA_EVENT_RETENTION_DAYS, and performance regressions follow the same period. Profile regressions followGOTCHA_PROFILE_RETENTION_DAYS, metric incidentsGOTCHA_METRIC_RETENTION_DAYS, and resolved uptime incidentsGOTCHA_INCIDENT_RETENTION_DAYS: a summary record must not outlive the data it describes, or its card opens onto nothing. An issue title is free text from your application, and it must not outlive the retention you declared. Open incidents and regressions are never deleted — they describe what is happening right now. A retention of zero disables deletion within its own class. - Anonymized external notifications. By default, error text reaches trusted recipients only — those on your own domains or internal network; everyone else, Telegram included, gets an anonymized link back to the instance. See the external-recipients section below.
- No phone-home. Gotcha sends no analytics or telemetry back to its developers. The only external recipients are the ones you configure (alert channels, SSO providers).
- SSRF protection. Outbound requests (webhook alerts, uptime checks) do not target private/loopback addresses by default (
GOTCHA_SSRF_ALLOW_PRIVATE=false).
Data subject rights: access and deletion
152-FZ (art. 14) and comparable rules give the subject a right to access and delete their personal data. In Gotcha this is available at the organization level (to the owner role):
- Export subject data — exports an end user’s data (by
user_idor email), including events, transactions (including by identifiers in tags), metrics, and logs (by identifiers inlog_attributes). - Delete subject data — removes the same data from ClickHouse, plus the spans of traces whose transactions belong to the subject (the spans table has no subject column of its own — it links to a transaction by
trace_id; see the boundary cases below for a trace with no transaction row, or with more than one participant).
Deleting a project or an organization. The PostgreSQL rows are removed by
cascade immediately, while the ClickHouse telemetry is queued for deletion in the
same transaction and carried out by a background worker: eight mutations over
months of data take minutes, and running them inside the HTTP request meant the
deletion was cut short by a timeout, leaving part of the data behind forever. The
page therefore says the cleanup is queued, not done. You can evidence that it
ran with the gotcha_purge_queue_depth and gotcha_purge_queue_oldest_seconds
metrics (see Self-monitoring): a growing age of the
oldest request means the deletion has not happened, and the reason for the last
attempt is recorded in the request itself.
Important: with the default scrubbing on, lookups by email and IP match nothing.
GOTCHA_SCRUB_IPandGOTCHA_SCRUB_EMAILare on by default, soevents.user_emailandevents.user_ipare nulled at ingest — there is nothing left to search by. The same applies totags/attributes/log_attributesin transactions, metric_points, and logs: the keysuser.email/enduser.emailcontain “email” and are nulled by the same rule, so a subject cannot be found by email there either. Useuser_id(and its attribute equivalentsuser.id/enduser.id): it is deliberately excluded from scrubbing for exactly this right. The export and deletion forms warn about this inline, and deletion reports a count (“deleted N records” or “no records matched”) so you can evidence that the request was carried out.
Free text (spans.data/description, profile_samples.stack, logs.body) is not deleted per-subject programmatically — a subject cannot be reliably identified inside arbitrary JSON/stack frames/message text. Those fields are cleared by TTL expiry (spans 30 days, transactions 90, metrics 30, profiles 7, logs 14 by default); profile_samples.stack is a deliberate choice to leave on TTL rather than delete per subject, for the same reason.
Span rows (not the content of data/description, but the records themselves) are still affected by subject deletion — indirectly, through the trace_id of the subject’s transactions. Two boundaries remain: a span belonging to a trace that no longer has a transaction row (for example, retention evicted it before the spans) survives until the spans’ own TTL; and a trace with more than one participant (a background job, a cross-user request) is deleted in full once at least one of its transactions belongs to the subject — a span is not split by subject within a single trace.
Account self-deletion. A Gotcha account holder can delete their own account from the profile page — linked sign-in methods, organization memberships, and sessions are removed by cascade. If they are the sole owner of an organization, they must transfer ownership or delete the organization first.
Free-text scrubbing
By default GOTCHA_SCRUB_FREETEXT=false: error text, stack traces, and span descriptions are stored verbatim — except for secrets inside URLs: denylisted query parameters, the fragment and basic-auth credentials are stripped from URLs regardless of this flag (naive masking would break SQL/URLs and reduce usefulness). If your developers might put personal data directly into error text, enable GOTCHA_SCRUB_FREETEXT=true (masks email in free text) and additionally configure scrubbing on the SDK side.
External recipients and cross-border transfer
When you connect an alert channel or SSO, personal data can leave your perimeter:
| Recipient | What is sent | Jurisdiction |
|---|---|---|
| Telegram | alert link and the recipient chat_id; the text only if the channel is marked as your own (or with GOTCHA_EXTERNAL_CHANNEL_DETAILS_ENABLED=true) | servers outside Russia |
| Email (SMTP) | the recipient address and the alert link; the text only for trusted recipients (below) | your SMTP server and the recipient’s mail server |
| Webhook | the alert payload; details only for trusted hosts (below) | the address you configured |
| OAuth/SSO (Yandex ID, VK ID, generic OIDC) | email/subject at sign-in | the provider (Yandex/VK — Russia) |
Sending error text (with possible personal data) outside your perimeter is a potential cross-border transfer (152-FZ art. 12). By default, event details — title, culprit, level, message body — therefore reach trusted recipients only; everyone else gets an anonymized notification with a link back to the instance.
A recipient is trusted when its address belongs to your infrastructure:
| Recipient | Trusted when |
|---|---|
the address domain is the instance host (or a subdomain of it), or is listed in GOTCHA_TRUSTED_RECIPIENTS | |
| Webhook | the URL host is the same, or any internal address: localhost, private ranges (10.0.0.0/8, 192.168.0.0/16, …), the .local, .internal, .lan, .home.arpa zones |
| Telegram | never by address: the recipient is a chat_id with no domain, and nothing about it can confirm it belongs to your perimeter |
| Any channel | the “This recipient is inside my perimeter” box on the channel — the operator states what the address cannot show |
The decision is made per recipient, not per channel type. A mailbox on a public mail service is someone else’s infrastructure exactly as Telegram is; a webhook pointed at your own server on an internal network never leaves your perimeter at all.
The box exists for the cases where the address proves nothing. Telegram is the usual one: the instance is self-hosted, the chat belongs to the operator, and no amount of parsing a chat_id will ever show that. Before it, the choice was between “nowhere” and GOTCHA_EXTERNAL_CHANNEL_DETAILS_ENABLED=true, meaning “everywhere, to everyone”. The box is ticked one channel at a time, by hand, and is off by default; channels carrying it are marked in the table with a “With details” badge, so who receives personal data is visible at a glance instead of by opening every channel in turn.
The statement is the operator’s responsibility: the product cannot verify it — which is precisely why the box is ticked by hand.
If your organization’s domain differs from the instance host, list it explicitly:
GOTCHA_TRUSTED_RECIPIENTS=corp.example,ops.corp.example
Matching happens on label boundaries: corp.example covers mail.corp.example but not evilcorp.example. The instance’s parent domain is not trusted automatically — walking one level up from gotcha.github.io would extend trust to all of github.io, meaning every unrelated project on that host.
GOTCHA_EXTERNAL_CHANNEL_DETAILS_ENABLED=true lifts the restriction entirely: details then go to every recipient of every project, Telegram included. When the goal is to open up one or two channels of your own, ticking the box on those channels is the better instrument — same result, and every other recipient stays protected.
The active policy is printed at startup: the line alert details: sent only to trusted recipients lists the instance host and the configured list.
Your obligations as an operator (152-FZ)
The following is a pointer, not an exhaustive list; check the current text of the law and a lawyer:
- Database localization (art. 18.5). Personal data of Russian citizens must be stored in databases located in Russia. Gotcha is not tied to any foreign hosting — deploy PostgreSQL and ClickHouse in the required jurisdiction.
- Notify Roskomnadzor of the intent to process personal data (except cases under art. 22).
- Publish a personal-data processing policy (art. 18.1 §2(2)). Use the inventory above as a starting point.
- Legal basis and consent of subjects where required.
- Protection measures for personal data: instance access, channel encryption (TLS), backups, role separation.
The technical means for compliance (scrubbing, retention, export/deletion, anonymized external notifications) are built in — but configuring them and the legal side remain the operator’s responsibility.