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_URL the app refuses to start with the default key in the web, all, ingest, and uptime modes (everywhere except probe), 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.

  1. Set GOTCHA_SECRET_KEY_PREV=<old key> and the new GOTCHA_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=true
    

    current_key_id (<new-id>) is the new key — that’s the one you’ll need in the next step. previous_key_id, together with rotation_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 use unreadable.

  2. A nonzero unreadable/unreadable_skipped isn’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 remove PREV.

    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 failed
    

    This is a secret that was readable and should have moved to the new key, but the targeted UPDATE for it didn’t land because of a SQL failure. That line is counted in neither unreadable nor unreadable_skipped — the counters look clean while the secret is still sitting on the old key. (There’s a second reason an UPDATE can 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 the secretbox keyring ready line 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 for LIKE '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.config is JSON that can hold several header values in one row; a plain NOT 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 one enc: occurrence not followed by v2:<new-id>:.

    All three should return 0.

  3. Remove GOTCHA_SECRET_KEY_PREV and 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

CategoryWhere it livesExamples
Gotcha account holdersPostgreSQL: users, user_identitiesemail at registration, email and subject from an SSO/OAuth provider
End users of observed applicationsClickHouse: events, transactions, metric_points, logsuser_id, user_ip, user_email, user.*/enduser.* attributes in tags (transactions), attributes (metric_points), log_attributes (logs)
Free text with possible personal dataClickHouse: events.message/exception_value/stacktrace/contexts, spans.description/data, profile_samples.stack, logs.body, tagsanything an SDK or developer placed into an error message, transaction name, SQL/URL, log message text
Notification delivery addressesPostgreSQL: org_invites.email, channel configurationinvitee emails, email/Telegram/webhook recipient addresses
Host telemetryPostgreSQL: 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_EMAIL on, the value of any field whose name (case- and separator-insensitive) contains email — or one of user_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 a customer_email tag becomes [scrubbed] even though it is not in GOTCHA_SCRUB_DENY_KEYS. Use GOTCHA_SCRUB_KEEP_KEYS to 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=email only unmasks a field whose normalized name is exactly email; it affects neither customer_email nor events.user_email (that one is nulled by the separate GOTCHA_SCRUB_EMAIL rule).
  • IP and email scrubbing on ingest. GOTCHA_SCRUB_IP=true and GOTCHA_SCRUB_EMAIL=true by default: events.user_ip and events.user_email are 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, so x_api_key, clientSecret and mytoken are all caught — and so are author (contains auth) and tokenizer (contains token). Under-redacting leaks personal data; over-redacting costs a debugging field and is reversible with GOTCHA_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 of 0 disables 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 follow GOTCHA_PROFILE_RETENTION_DAYS, metric incidents GOTCHA_METRIC_RETENTION_DAYS, and resolved uptime incidents GOTCHA_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_id or email), including events, transactions (including by identifiers in tags), metrics, and logs (by identifiers in log_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_IP and GOTCHA_SCRUB_EMAIL are on by default, so events.user_email and events.user_ip are nulled at ingest — there is nothing left to search by. The same applies to tags/attributes/log_attributes in transactions, metric_points, and logs: the keys user.email/enduser.email contain “email” and are nulled by the same rule, so a subject cannot be found by email there either. Use user_id (and its attribute equivalents user.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:

RecipientWhat is sentJurisdiction
Telegramalert 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
Webhookthe alert payload; details only for trusted hosts (below)the address you configured
OAuth/SSO (Yandex ID, VK ID, generic OIDC)email/subject at sign-inthe 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:

RecipientTrusted when
Emailthe address domain is the instance host (or a subdomain of it), or is listed in GOTCHA_TRUSTED_RECIPIENTS
Webhookthe 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
Telegramnever by address: the recipient is a chat_id with no domain, and nothing about it can confirm it belongs to your perimeter
Any channelthe “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.