From 0f69010d87016d648a1671a1a19bb49f32ae6d82 Mon Sep 17 00:00:00 2001 From: Cheese Date: Wed, 9 Sep 2026 11:26:57 -0400 Subject: [PATCH] feat: refine telemetry storage and filesystem guidance --- AGENTS.md | 4 +- Makefile | 4 +- README.md | 2 +- cmd/ti-telemetry-backend/main.go | 15 +- deploy/telemetry/.env.example | 2 - docs/spec/done/0022-telemetry.md | 14 +- .../0025-telemetry-environment-metadata.md | 7 +- docs/tdc-telemetry-metabase-dashboard.sql | 435 ++++++++++++++++-- docs/telemetry-backend-design.md | 115 +---- e2e/telemetry_test.go | 26 +- internal/api/client.go | 55 ++- internal/api/client_test.go | 16 +- internal/api/error.go | 19 +- internal/fs/tenant_control.go | 29 ++ internal/fs/tenant_control_test.go | 72 +++ internal/telemetrybackend/batcher.go | 12 - internal/telemetrybackend/batcher_test.go | 30 +- internal/telemetrybackend/config.go | 15 - internal/telemetrybackend/config_test.go | 4 - internal/telemetrybackend/posthog.go | 140 ------ internal/telemetrybackend/posthog_test.go | 72 --- internal/telemetrybackend/server.go | 17 +- internal/telemetrybackend/server_test.go | 27 +- .../telemetrybackend/test_helpers_test.go | 2 - ref/drive9 | 2 +- ref/fs | 2 +- 26 files changed, 631 insertions(+), 507 deletions(-) delete mode 100644 internal/telemetrybackend/posthog.go delete mode 100644 internal/telemetrybackend/posthog_test.go diff --git a/AGENTS.md b/AGENTS.md index 65b9ead..b2de7c2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -181,7 +181,7 @@ Implemented: - GoReleaser/GitHub Releases install and update workflow - Makefile build/test/e2e workflow - independent telemetry ingestion backend with strict schema validation, - bounded in-memory batching, TiDB storage, and personless PostHog forwarding + bounded in-memory batching, and TiDB storage There are no registered placeholder commands at the current stage. Implemented mutating commands support `--dry-run` where their command contract declares @@ -363,7 +363,7 @@ internal/query/ JMESPath query application internal/secretinput/ no-echo secret input helper internal/settings/ global settings parsing and legacy logging migration internal/telemetry/ CLI eligibility, identity, event, and delivery path -internal/telemetrybackend/ telemetry API, batcher, TiDB, and PostHog sinks +internal/telemetrybackend/ telemetry API, batcher, and TiDB sink internal/update/ GitHub Releases update checks and self-update logic internal/version/ build version metadata scripts/ installer scripts diff --git a/Makefile b/Makefile index 2d54554..030b89e 100644 --- a/Makefile +++ b/Makefile @@ -50,7 +50,9 @@ e2e: build telemetry-e2e: build build-telemetry-backend build-telemetry-migrator @test -f "$(TELEMETRY_E2E_ENV)" || { echo "missing $(TELEMETRY_E2E_ENV); set TI_TEST_TELEMETRY_TIDB_DSN in that ignored file" >&2; exit 2; } @set -a; . "$(TELEMETRY_E2E_ENV)"; set +a; \ - TI_E2E_BIN="$(abspath $(TI_BIN))" TI_TELEMETRY_BACKEND_E2E_BIN="$(abspath $(TELEMETRY_BACKEND_BIN))" TI_TELEMETRY_MIGRATOR_E2E_BIN="$(abspath $(TELEMETRY_MIGRATOR_BIN))" TI_TELEMETRY_E2E=1 $(GO) test ./e2e -count=1 -v -run '^TestTelemetryDeliveryToTiDB$$' + TI_TEST_TELEMETRY_TIDB_DSN="$${TI_TEST_TELEMETRY_TIDB_DSN:-$${TDC_TEST_TELEMETRY_TIDB_DSN:-}}"; \ + test -n "$$TI_TEST_TELEMETRY_TIDB_DSN" || { echo "TI_TEST_TELEMETRY_TIDB_DSN is required in $(TELEMETRY_E2E_ENV)" >&2; exit 2; }; \ + TI_TEST_TELEMETRY_TIDB_DSN="$$TI_TEST_TELEMETRY_TIDB_DSN" TI_E2E_BIN="$(abspath $(TI_BIN))" TI_TELEMETRY_BACKEND_E2E_BIN="$(abspath $(TELEMETRY_BACKEND_BIN))" TI_TELEMETRY_MIGRATOR_E2E_BIN="$(abspath $(TELEMETRY_MIGRATOR_BIN))" TI_TELEMETRY_E2E=1 $(GO) test ./e2e -count=1 -v -run '^TestTelemetryDeliveryToTiDB$$' live-e2e: build $(LIVE_E2E_RUN) -run '^TestLive' diff --git a/README.md b/README.md index 00ed44f..765f840 100644 --- a/README.md +++ b/README.md @@ -434,7 +434,7 @@ Build the independently deployed telemetry ingestion service: make build-telemetry-backend ``` -The backend binary is written to `bin/ti-telemetry-backend`. Its API, privacy contract, TiDB/PostHog batching behavior, and Docker deployment are documented in [Telemetry Backend Design](docs/telemetry-backend-design.md). +The backend binary is written to `bin/ti-telemetry-backend`. Its API, privacy contract, TiDB batching behavior, and Docker deployment are documented in [Telemetry Backend Design](docs/telemetry-backend-design.md). ## Test diff --git a/cmd/ti-telemetry-backend/main.go b/cmd/ti-telemetry-backend/main.go index 92b3636..43287eb 100644 --- a/cmd/ti-telemetry-backend/main.go +++ b/cmd/ti-telemetry-backend/main.go @@ -35,26 +35,15 @@ func main() { tidbSink := telemetrybackend.NewTiDBSink(db) - postHogSink, err := telemetrybackend.NewPostHogSink( - config.PostHogAPIHost, - config.PostHogProjectToken, - config.Environment, - &http.Client{}, - ) - if err != nil { - logger.Error("initialize PostHog sink failed") - os.Exit(1) - } - metrics := &telemetrybackend.Metrics{} batcher := telemetrybackend.NewBatcher( config, - []telemetrybackend.Sink{tidbSink, postHogSink}, + []telemetrybackend.Sink{tidbSink}, logger, metrics, ) batcher.Start() - api := telemetrybackend.NewServer(config, batcher, tidbSink, postHogSink, logger, metrics) + api := telemetrybackend.NewServer(config, batcher, tidbSink, logger, metrics) httpServer := &http.Server{ Addr: config.BindAddr, Handler: api.Handler(), diff --git a/deploy/telemetry/.env.example b/deploy/telemetry/.env.example index fd02499..df57729 100644 --- a/deploy/telemetry/.env.example +++ b/deploy/telemetry/.env.example @@ -13,5 +13,3 @@ TELEMETRY_RATE_LIMIT_PER_MINUTE=60 TELEMETRY_RATE_LIMIT_BURST=120 TELEMETRY_TRUSTED_PROXY_CIDRS=172.16.0.0/12 TIDB_DSN=tdc_telemetry:replace-me@tcp(gateway01.us-east-1.prod.aws.tidbcloud.com:4000)/tdc_telemetry?tls=true&parseTime=true -POSTHOG_API_HOST=https://us.i.posthog.com -POSTHOG_PROJECT_TOKEN=replace-me diff --git a/docs/spec/done/0022-telemetry.md b/docs/spec/done/0022-telemetry.md index bbbb138..898d821 100644 --- a/docs/spec/done/0022-telemetry.md +++ b/docs/spec/done/0022-telemetry.md @@ -4,7 +4,7 @@ Collect minimal, privacy-preserving CLI telemetry that helps improve tdc reliability and command UX without capturing sensitive user data or adding telemetry management commands to the public CLI surface. -Telemetry is routed only through a product-owned HTTPS backend. The CLI never sends events directly to PostHog or another third-party analytics endpoint. +Telemetry is routed only through a product-owned HTTPS backend and stored only in TiDB. The CLI and backend do not send events to a third-party analytics endpoint. ## Product Decisions @@ -14,8 +14,8 @@ Telemetry is routed only through a product-owned HTTPS backend. The CLI never se - Do not add `tdc cli describe-telemetry`, `tdc cli enable-telemetry`, `tdc cli disable-telemetry`, or another telemetry command. - `tdc update`, help, version, and commandless usage invocations never send telemetry. - Telemetry is best-effort and lossy. Delivery must not change command stdout, stderr, output format, exit code, or user-visible result. -- The backend returns `202 Accepted` after validated events enter its bounded in-memory batcher. This does not guarantee that TiDB or PostHog has completed its sink write. -- No local durable queue, MQ, Kafka, SQS, Pub/Sub, or TiDB-to-PostHog consumer is required for MVP. +- The backend returns `202 Accepted` after validated events enter its bounded in-memory batcher. This does not guarantee that TiDB has completed its sink write. +- No local durable queue, MQ, Kafka, SQS, Pub/Sub, or downstream forwarding consumer is required for MVP. ## Eligible Commands @@ -231,7 +231,7 @@ Delivery behavior: 6. The CLI boundary maps the result to stable exit and application error codes. 7. The telemetry package constructs one allowlisted event and posts it to `POST /v1/telemetry/batch`. 8. The backend validates the schema, enqueues accepted events, and returns `202 Accepted`. -9. The backend flush loop independently writes the same sanitized batch to TiDB and PostHog. +9. The backend flush loop writes the sanitized batch to TiDB. 10. The CLI ignores the delivery result except for optional redacted debug diagnostics. ## Package Design @@ -256,8 +256,8 @@ The CLI depends on these guarantees: - valid event batches are acknowledged with `202 Accepted` after entering a bounded in-memory buffer; - unknown or prohibited fields are rejected; - accepted events are best-effort and may be lost before sink flush; -- TiDB and PostHog receive the same sanitized event batch through independent sink attempts; -- PostHog person profiles are disabled with `$process_person_profile = false`; +- TiDB is the only persistent telemetry destination; +- the backend does not forward telemetry to third-party analytics services; - no CLI-shipped backend credential is required. ## Acceptance Criteria @@ -297,4 +297,4 @@ The CLI depends on these guarantees: - User-configurable telemetry endpoints. - Capturing command output, API response bodies, SQL text, paths, file contents, credentials, flag values, raw errors, host identity, or cloud resource IDs. - Local durable telemetry queues. -- MQ, Kafka, SQS, Pub/Sub, durable outbox tables, or TiDB-to-PostHog consumer workflows. +- MQ, Kafka, SQS, Pub/Sub, durable outbox tables, or downstream forwarding workflows. diff --git a/docs/spec/done/0025-telemetry-environment-metadata.md b/docs/spec/done/0025-telemetry-environment-metadata.md index fa6283f..97910f4 100644 --- a/docs/spec/done/0025-telemetry-environment-metadata.md +++ b/docs/spec/done/0025-telemetry-environment-metadata.md @@ -156,7 +156,7 @@ ALTER TABLE telemetry_events The implementation must account for repeated startup and partially applied migration state rather than assuming a new database. Do not index `extra_json`; ad hoc JSON inspection is allowed, but a repeatedly queried key should become a separately designed first-class field later. -TiDB receives `tag` and complete `extra_json`. PostHog receives `tag` and `extra` as nested event properties. The backend must not flatten arbitrary extra keys into top-level PostHog properties or use either field as `distinct_id` or person properties. +TiDB receives `tag` and complete `extra_json`. The backend must not flatten arbitrary extra keys into first-class columns or use either field as an identity. ## Failure Behavior @@ -189,10 +189,9 @@ Backend tests must cover: - strict UTF-8, size, depth, prohibited-key, and unknown-field rejection; - TiDB migration behavior for a new and existing schema; - TiDB batch insertion preserving JSON type; -- PostHog nested properties with person profiles disabled; -- independent TiDB and PostHog sink failure behavior remaining unchanged. +- TiDB sink failure behavior remaining best-effort and non-fatal to the CLI. -Black-box `make e2e` uses a local telemetry receiver to inspect schema v2 payloads without contacting production. A separate opt-in `make telemetry-e2e` loads the ignored `e2e/.env.telemetry` file and requires a test-only `TDC_TEST_TELEMETRY_TIDB_DSN` with database create/drop privileges. It creates a unique empty database, migrates it through legacy schema version 1, inserts a legacy event, migrates to the latest version, proves that event is preserved, then starts a local telemetry backend and fake PostHog receiver. It executes a no-side-effect CLI dry run against that local backend, verifies the stored schema v2 event and extra JSON, and drops only its temporary database. Ordinary `make test`, `make e2e`, and all live-e2e targets must not read the dotenv file or require a live TiDB instance. +Black-box `make e2e` uses a local telemetry receiver to inspect schema v2 payloads without contacting production. A separate opt-in `make telemetry-e2e` loads the ignored `e2e/.env.telemetry` file and requires a test-only `TI_TEST_TELEMETRY_TIDB_DSN` with database create/drop privileges; the test target also accepts the legacy pre-v0.2 `TDC_TEST_TELEMETRY_TIDB_DSN` name. It creates a unique empty database, migrates it through legacy schema version 1, inserts a legacy event, migrates to the latest version, proves that event is preserved, then starts a local telemetry backend. It executes a no-side-effect CLI dry run against that local backend, verifies the stored schema v2 event and extra JSON, and drops only its temporary database. Ordinary `make test`, `make e2e`, and all live-e2e targets must not read the dotenv file or require a live TiDB instance. ## Documentation Updates diff --git a/docs/tdc-telemetry-metabase-dashboard.sql b/docs/tdc-telemetry-metabase-dashboard.sql index f9f329e..306e650 100644 --- a/docs/tdc-telemetry-metabase-dashboard.sql +++ b/docs/tdc-telemetry-metabase-dashboard.sql @@ -1,10 +1,14 @@ --- tdc telemetry dashboard queries for TiDB + Metabase. +-- TiDB Cloud CLI telemetry dashboard queries for TiDB + Metabase. -- -- For scoped cards, configure these optional Metabase basic variables: -- start_date -> Date -- end_date -> Date -- region_code -> Text -- cli_version -> Text +-- lab_scope -> Text with values: with_lab, without_lab, lab_only +-- Cards 14-16 additionally use these optional Text variables: +-- error_command -> the displayed command without the ti/tdc prefix +-- error_code -> the stable telemetry error code -- -- Leave every variable unset to query the complete global dataset. The global -- KPI card intentionally has no variables and is never narrowed by dashboard filters. @@ -16,13 +20,18 @@ -- -- METABASE SETUP -- 1. Save each CARD below as a separate native SQL question. --- 2. For cards 2-13, configure start_date and end_date as optional Date variables. +-- 2. For cards 2-16, configure start_date and end_date as optional Date variables. -- 3. Configure region_code and cli_version as optional Text variables. Prefer a -- dropdown populated from telemetry_events.region_code or cli_version. --- 4. Add four dashboard filters: From date, Through date, Region, and CLI version. --- Connect them to start_date, end_date, region_code, and cli_version respectively. --- 5. Do not connect dashboard filters to card 1. It is the all-time global baseline. --- 6. Use UTC for the dashboard reporting timezone because received_at is backend time. +-- 4. Configure lab_scope as a Text dropdown containing with_lab, without_lab, and +-- lab_only. Leaving it unset has the same behavior as with_lab. +-- 5. Add five dashboard filters: From date, Through date, Region, CLI version, and +-- Lab scope. Connect them to the matching variables. +-- 6. For error drill-down, create a separate Error Details dashboard containing cards +-- 14-16. Configure card 5 click behavior to open it, mapping Command to +-- error_command and Error Code to error_code. +-- 7. Do not connect dashboard filters to card 1. It is the all-time global baseline. +-- 8. Use UTC for the dashboard reporting timezone because received_at is backend time. -- -- RECOMMENDED DASHBOARD LAYOUT -- Row 1: card 1 global KPI numbers and card 2 selected-scope KPI numbers. @@ -32,33 +41,58 @@ -- Row 5: card 9 repeat usage and card 10 version adoption. -- Row 6: card 11 --wait adoption, card 12 platform distribution, and card 13 install -- source distribution. +-- Error Details dashboard: card 14 breakdown, card 15 daily trend, and card 16 events. -- CARD 1: Global lifetime KPIs -- Visualization: four Number cards, one for each returned metric. In Metabase, -- duplicate the question and retain one SELECT expression in each copy. A single-row -- Table is an acceptable compact alternative. Do not connect dashboard filters. +WITH normalized AS ( + SELECT + e.*, + CASE + WHEN command_path = 'tdc' THEN 'ti' + WHEN command_path LIKE 'tdc %' THEN CONCAT('ti', SUBSTRING(command_path, 4)) + ELSE command_path + END AS canonical_command_path + FROM `tdc_telemetry`.`telemetry_events` e +) SELECT COUNT(DISTINCT anonymous_installation_id) AS `Active Installations`, COUNT(*) AS `Command Invocations`, - COUNT(DISTINCT command_path) AS `Commands Used`, + COUNT(DISTINCT canonical_command_path) AS `Commands Used`, ROUND(1.0 * SUM(CASE WHEN exit_code = 0 THEN 1 ELSE 0 END) / NULLIF(COUNT(*), 0), 4) AS `Success Rate` -FROM `tdc_telemetry`.`telemetry_events`; +FROM normalized; -- CARD 2: Scoped KPIs --- Visualization: four Number cards, one for each returned metric. Connect all four +-- Visualization: four Number cards, one for each returned metric. Connect all five -- dashboard filters. With no variables set, this query also represents global data. -WITH scoped AS ( - SELECT * FROM `tdc_telemetry`.`telemetry_events` +WITH normalized AS ( + SELECT + e.*, + CASE + WHEN command_path = 'tdc' THEN 'ti' + WHEN command_path LIKE 'tdc %' THEN CONCAT('ti', SUBSTRING(command_path, 4)) + ELSE command_path + END AS canonical_command_path + FROM `tdc_telemetry`.`telemetry_events` e +), scoped AS ( + SELECT * FROM normalized WHERE 1 = 1 [[AND received_at >= {{start_date}}]] [[AND received_at < DATE_ADD({{end_date}}, INTERVAL 1 DAY)]] [[AND region_code = {{region_code}}]] [[AND cli_version = {{cli_version}}]] + [[AND ( + {{lab_scope}} = 'with_lab' + OR ({{lab_scope}} = 'without_lab' AND tag <> 'tidb-labs') + OR ({{lab_scope}} = 'lab_only' AND tag = 'tidb-labs') + )]] ) SELECT COUNT(DISTINCT anonymous_installation_id) AS `Active Installations`, COUNT(*) AS `Command Invocations`, - COUNT(DISTINCT command_path) AS `Commands Used`, + COUNT(DISTINCT canonical_command_path) AS `Commands Used`, ROUND(1.0 * SUM(CASE WHEN exit_code = 0 THEN 1 ELSE 0 END) / NULLIF(COUNT(*), 0), 4) AS `Success Rate` FROM scoped; @@ -73,6 +107,11 @@ WITH scoped AS ( [[AND received_at < DATE_ADD({{end_date}}, INTERVAL 1 DAY)]] [[AND region_code = {{region_code}}]] [[AND cli_version = {{cli_version}}]] + [[AND ( + {{lab_scope}} = 'with_lab' + OR ({{lab_scope}} = 'without_lab' AND tag <> 'tidb-labs') + OR ({{lab_scope}} = 'lab_only' AND tag = 'tidb-labs') + )]] ) SELECT DATE(received_at) AS `Activity Date`, @@ -88,97 +127,172 @@ ORDER BY `Activity Date`; -- Success Rate and Failures, and format Success Rate as Percentage. For an -- adoption-only view, use a horizontal bar chart with Command as the category and -- Installations as the value. -WITH scoped AS ( - SELECT * FROM `tdc_telemetry`.`telemetry_events` +WITH normalized AS ( + SELECT + e.*, + CASE + WHEN command_path = 'tdc' THEN 'ti' + WHEN command_path LIKE 'tdc %' THEN CONCAT('ti', SUBSTRING(command_path, 4)) + ELSE command_path + END AS canonical_command_path + FROM `tdc_telemetry`.`telemetry_events` e +), scoped AS ( + SELECT + normalized.*, + CASE + WHEN canonical_command_path LIKE 'ti %' THEN SUBSTRING(canonical_command_path, 4) + ELSE canonical_command_path + END AS display_command + FROM normalized WHERE 1 = 1 [[AND received_at >= {{start_date}}]] [[AND received_at < DATE_ADD({{end_date}}, INTERVAL 1 DAY)]] [[AND region_code = {{region_code}}]] [[AND cli_version = {{cli_version}}]] + [[AND ( + {{lab_scope}} = 'with_lab' + OR ({{lab_scope}} = 'without_lab' AND tag <> 'tidb-labs') + OR ({{lab_scope}} = 'lab_only' AND tag = 'tidb-labs') + )]] ) SELECT - command_path AS `Command`, + display_command AS `Command`, COUNT(DISTINCT anonymous_installation_id) AS `Installations`, COUNT(*) AS `Invocations`, SUM(CASE WHEN exit_code <> 0 THEN 1 ELSE 0 END) AS `Failures`, ROUND(1.0 * SUM(CASE WHEN exit_code = 0 THEN 1 ELSE 0 END) / NULLIF(COUNT(*), 0), 4) AS `Success Rate`, ROUND(AVG(duration_ms), 0) AS `Average Duration (ms)` FROM scoped -GROUP BY command_path +GROUP BY display_command ORDER BY `Installations` DESC, `Invocations` DESC; -- CARD 5: Top actionable errors -- Visualization: Table with Failures and Affected Installations, or a horizontal -- stacked bar chart using Command as the category, Failures as the value, and Error -- Code as the series. -WITH scoped AS ( - SELECT * FROM `tdc_telemetry`.`telemetry_events` +WITH normalized AS ( + SELECT + e.*, + CASE + WHEN command_path = 'tdc' THEN 'ti' + WHEN command_path LIKE 'tdc %' THEN CONCAT('ti', SUBSTRING(command_path, 4)) + ELSE command_path + END AS canonical_command_path + FROM `tdc_telemetry`.`telemetry_events` e +), scoped AS ( + SELECT + normalized.*, + CASE + WHEN canonical_command_path LIKE 'ti %' THEN SUBSTRING(canonical_command_path, 4) + ELSE canonical_command_path + END AS display_command, + COALESCE(NULLIF(error_code, ''), 'unclassified') AS normalized_error_code + FROM normalized WHERE 1 = 1 [[AND received_at >= {{start_date}}]] [[AND received_at < DATE_ADD({{end_date}}, INTERVAL 1 DAY)]] [[AND region_code = {{region_code}}]] [[AND cli_version = {{cli_version}}]] + [[AND ( + {{lab_scope}} = 'with_lab' + OR ({{lab_scope}} = 'without_lab' AND tag <> 'tidb-labs') + OR ({{lab_scope}} = 'lab_only' AND tag = 'tidb-labs') + )]] ) SELECT - command_path AS `Command`, - COALESCE(NULLIF(error_code, ''), 'unclassified') AS `Error Code`, + display_command AS `Command`, + normalized_error_code AS `Error Code`, COUNT(*) AS `Failures`, COUNT(DISTINCT anonymous_installation_id) AS `Affected Installations` FROM scoped WHERE exit_code <> 0 -GROUP BY command_path, COALESCE(NULLIF(error_code, ''), 'unclassified') +GROUP BY display_command, normalized_error_code ORDER BY `Failures` DESC, `Affected Installations` DESC LIMIT 30; -- CARD 6: Successful-command latency, exact nearest-rank p50/p95 -- Visualization: Table sorted by P95 Duration (ms). Apply conditional formatting to -- that column and retain Samples so low-volume commands are not overinterpreted. -WITH scoped AS ( - SELECT * FROM `tdc_telemetry`.`telemetry_events` +WITH normalized AS ( + SELECT + e.*, + CASE + WHEN command_path = 'tdc' THEN 'ti' + WHEN command_path LIKE 'tdc %' THEN CONCAT('ti', SUBSTRING(command_path, 4)) + ELSE command_path + END AS canonical_command_path + FROM `tdc_telemetry`.`telemetry_events` e +), scoped AS ( + SELECT + normalized.*, + CASE + WHEN canonical_command_path LIKE 'ti %' THEN SUBSTRING(canonical_command_path, 4) + ELSE canonical_command_path + END AS display_command + FROM normalized WHERE exit_code = 0 [[AND received_at >= {{start_date}}]] [[AND received_at < DATE_ADD({{end_date}}, INTERVAL 1 DAY)]] [[AND region_code = {{region_code}}]] [[AND cli_version = {{cli_version}}]] + [[AND ( + {{lab_scope}} = 'with_lab' + OR ({{lab_scope}} = 'without_lab' AND tag <> 'tidb-labs') + OR ({{lab_scope}} = 'lab_only' AND tag = 'tidb-labs') + )]] ), ranked AS ( SELECT - command_path, + display_command, duration_ms, - ROW_NUMBER() OVER (PARTITION BY command_path ORDER BY duration_ms) AS rank_no, - COUNT(*) OVER (PARTITION BY command_path) AS sample_count + ROW_NUMBER() OVER (PARTITION BY display_command ORDER BY duration_ms) AS rank_no, + COUNT(*) OVER (PARTITION BY display_command) AS sample_count FROM scoped ) SELECT - command_path AS `Command`, + display_command AS `Command`, MAX(sample_count) AS `Samples`, ROUND(AVG(duration_ms), 0) AS `Average Duration (ms)`, MAX(CASE WHEN rank_no = CEIL(sample_count * 0.50) THEN duration_ms END) AS `P50 Duration (ms)`, MAX(CASE WHEN rank_no = CEIL(sample_count * 0.95) THEN duration_ms END) AS `P95 Duration (ms)` FROM ranked -GROUP BY command_path +GROUP BY display_command HAVING MAX(sample_count) >= 5 ORDER BY `P95 Duration (ms)` DESC; -- CARD 7: Starter DB activation funnel -- Visualization: Funnel. Use Step as the stage and Installations as the value; sort -- by Step Order ascending and hide Step Order from the displayed result when possible. -WITH scoped AS ( - SELECT * FROM `tdc_telemetry`.`telemetry_events` +WITH normalized AS ( + SELECT + e.*, + CASE + WHEN command_path = 'tdc' THEN 'ti' + WHEN command_path LIKE 'tdc %' THEN CONCAT('ti', SUBSTRING(command_path, 4)) + ELSE command_path + END AS canonical_command_path + FROM `tdc_telemetry`.`telemetry_events` e +), scoped AS ( + SELECT * FROM normalized WHERE 1 = 1 [[AND received_at >= {{start_date}}]] [[AND received_at < DATE_ADD({{end_date}}, INTERVAL 1 DAY)]] [[AND region_code = {{region_code}}]] [[AND cli_version = {{cli_version}}]] + [[AND ( + {{lab_scope}} = 'with_lab' + OR ({{lab_scope}} = 'without_lab' AND tag <> 'tidb-labs') + OR ({{lab_scope}} = 'lab_only' AND tag = 'tidb-labs') + )]] ), creators AS ( SELECT anonymous_installation_id, MIN(received_at) AS created_at FROM scoped - WHERE command_path = 'tdc db create-db-cluster' AND exit_code = 0 + WHERE canonical_command_path = 'ti db create-db-cluster' AND exit_code = 0 GROUP BY anonymous_installation_id ), prepared AS ( SELECT c.anonymous_installation_id, MIN(e.received_at) AS prepared_at FROM creators c JOIN scoped e ON e.anonymous_installation_id = c.anonymous_installation_id - AND e.command_path = 'tdc db create-db-sql-users' + AND e.canonical_command_path = 'ti db create-db-sql-users' AND e.exit_code = 0 AND e.received_at >= c.created_at GROUP BY c.anonymous_installation_id @@ -186,7 +300,7 @@ WITH scoped AS ( SELECT p.anonymous_installation_id, MIN(e.received_at) AS queried_at FROM prepared p JOIN scoped e ON e.anonymous_installation_id = p.anonymous_installation_id - AND e.command_path = 'tdc db execute-sql-statement' + AND e.canonical_command_path = 'ti db execute-sql-statement' AND e.exit_code = 0 AND e.received_at >= p.prepared_at GROUP BY p.anonymous_installation_id @@ -201,17 +315,31 @@ ORDER BY `Step Order`; -- CARD 8: Filesystem activation funnel -- Visualization: Funnel. Use Step as the stage and Installations as the value; sort -- by Step Order ascending and hide Step Order from the displayed result when possible. -WITH scoped AS ( - SELECT * FROM `tdc_telemetry`.`telemetry_events` +WITH normalized AS ( + SELECT + e.*, + CASE + WHEN command_path = 'tdc' THEN 'ti' + WHEN command_path LIKE 'tdc %' THEN CONCAT('ti', SUBSTRING(command_path, 4)) + ELSE command_path + END AS canonical_command_path + FROM `tdc_telemetry`.`telemetry_events` e +), scoped AS ( + SELECT * FROM normalized WHERE 1 = 1 [[AND received_at >= {{start_date}}]] [[AND received_at < DATE_ADD({{end_date}}, INTERVAL 1 DAY)]] [[AND region_code = {{region_code}}]] [[AND cli_version = {{cli_version}}]] + [[AND ( + {{lab_scope}} = 'with_lab' + OR ({{lab_scope}} = 'without_lab' AND tag <> 'tidb-labs') + OR ({{lab_scope}} = 'lab_only' AND tag = 'tidb-labs') + )]] ), creators AS ( SELECT anonymous_installation_id, MIN(received_at) AS created_at FROM scoped - WHERE command_path = 'tdc fs create-file-system' AND exit_code = 0 + WHERE canonical_command_path = 'ti fs create-file-system' AND exit_code = 0 GROUP BY anonymous_installation_id ), accesses AS ( SELECT @@ -219,11 +347,11 @@ WITH scoped AS ( COUNT(*) AS access_count FROM creators c JOIN scoped e ON e.anonymous_installation_id = c.anonymous_installation_id - AND e.command_path IN ( - 'tdc fs mount-file-system', - 'tdc fs copy-file', - 'tdc fs read-file', - 'tdc fs list-files' + AND e.canonical_command_path IN ( + 'ti fs mount-file-system', + 'ti fs copy-file', + 'ti fs read-file', + 'ti fs list-files' ) AND e.exit_code = 0 AND e.received_at >= c.created_at @@ -246,6 +374,11 @@ WITH scoped AS ( [[AND received_at < DATE_ADD({{end_date}}, INTERVAL 1 DAY)]] [[AND region_code = {{region_code}}]] [[AND cli_version = {{cli_version}}]] + [[AND ( + {{lab_scope}} = 'with_lab' + OR ({{lab_scope}} = 'without_lab' AND tag <> 'tidb-labs') + OR ({{lab_scope}} = 'lab_only' AND tag = 'tidb-labs') + )]] ), installation_activity AS ( SELECT anonymous_installation_id, @@ -284,6 +417,11 @@ WITH scoped AS ( [[AND received_at < DATE_ADD({{end_date}}, INTERVAL 1 DAY)]] [[AND region_code = {{region_code}}]] [[AND cli_version = {{cli_version}}]] + [[AND ( + {{lab_scope}} = 'with_lab' + OR ({{lab_scope}} = 'without_lab' AND tag <> 'tidb-labs') + OR ({{lab_scope}} = 'lab_only' AND tag = 'tidb-labs') + )]] ) SELECT cli_version AS `CLI Version`, @@ -298,21 +436,35 @@ ORDER BY `Installations` DESC, `Invocations` DESC; -- Visualization: Horizontal bar chart with Command as the category and Wait Adoption -- as the value. Format Wait Adoption as Percentage. Show Invocations in the tooltip or -- use a Table when sample size needs to remain visible. -WITH scoped AS ( - SELECT * FROM `tdc_telemetry`.`telemetry_events` - WHERE command_path IN ( - 'tdc db create-db-cluster', - 'tdc db create-db-cluster-branch', - 'tdc db delete-db-cluster', - 'tdc fs create-file-system' +WITH normalized AS ( + SELECT + e.*, + CASE + WHEN command_path = 'tdc' THEN 'ti' + WHEN command_path LIKE 'tdc %' THEN CONCAT('ti', SUBSTRING(command_path, 4)) + ELSE command_path + END AS canonical_command_path + FROM `tdc_telemetry`.`telemetry_events` e +), scoped AS ( + SELECT * FROM normalized + WHERE canonical_command_path IN ( + 'ti db create-db-cluster', + 'ti db create-db-cluster-branch', + 'ti db delete-db-cluster', + 'ti fs create-file-system' ) [[AND received_at >= {{start_date}}]] [[AND received_at < DATE_ADD({{end_date}}, INTERVAL 1 DAY)]] [[AND region_code = {{region_code}}]] [[AND cli_version = {{cli_version}}]] + [[AND ( + {{lab_scope}} = 'with_lab' + OR ({{lab_scope}} = 'without_lab' AND tag <> 'tidb-labs') + OR ({{lab_scope}} = 'lab_only' AND tag = 'tidb-labs') + )]] ) SELECT - command_path AS `Command`, + SUBSTRING(canonical_command_path, 4) AS `Command`, COUNT(*) AS `Invocations`, SUM(CASE WHEN JSON_CONTAINS(flag_names_json, JSON_QUOTE('wait')) = 1 THEN 1 ELSE 0 END) AS `Wait Invocations`, ROUND( @@ -321,7 +473,7 @@ SELECT 4 ) AS `Wait Adoption` FROM scoped -GROUP BY command_path +GROUP BY canonical_command_path ORDER BY `Invocations` DESC; -- CARD 12: Platform distribution by operating system and architecture @@ -336,6 +488,11 @@ WITH scoped AS ( [[AND received_at < DATE_ADD({{end_date}}, INTERVAL 1 DAY)]] [[AND region_code = {{region_code}}]] [[AND cli_version = {{cli_version}}]] + [[AND ( + {{lab_scope}} = 'with_lab' + OR ({{lab_scope}} = 'without_lab' AND tag <> 'tidb-labs') + OR ({{lab_scope}} = 'lab_only' AND tag = 'tidb-labs') + )]] ) SELECT os AS `Operating System`, @@ -357,6 +514,11 @@ WITH scoped AS ( [[AND received_at < DATE_ADD({{end_date}}, INTERVAL 1 DAY)]] [[AND region_code = {{region_code}}]] [[AND cli_version = {{cli_version}}]] + [[AND ( + {{lab_scope}} = 'with_lab' + OR ({{lab_scope}} = 'without_lab' AND tag <> 'tidb-labs') + OR ({{lab_scope}} = 'lab_only' AND tag = 'tidb-labs') + )]] ) SELECT COALESCE(NULLIF(install_source, ''), 'unknown') AS `Install Source`, @@ -364,3 +526,176 @@ SELECT FROM scoped GROUP BY COALESCE(NULLIF(install_source, ''), 'unknown') ORDER BY `Installations` DESC; + +-- CARD 14: Error drill-down breakdown +-- Visualization: Table sorted by Failures. Put this card on a separate Error Details +-- dashboard. Card 5 should pass its Command and Error Code values into error_command +-- and error_code. Do not expose event_id or anonymous_installation_id in this table. +WITH normalized AS ( + SELECT + e.*, + CASE + WHEN command_path = 'tdc' THEN 'ti' + WHEN command_path LIKE 'tdc %' THEN CONCAT('ti', SUBSTRING(command_path, 4)) + ELSE command_path + END AS canonical_command_path + FROM `tdc_telemetry`.`telemetry_events` e +), prepared AS ( + SELECT + normalized.*, + CASE + WHEN canonical_command_path LIKE 'ti %' THEN SUBSTRING(canonical_command_path, 4) + ELSE canonical_command_path + END AS display_command, + COALESCE(NULLIF(error_code, ''), 'unclassified') AS normalized_error_code, + COALESCE(NULLIF(region_code, ''), 'unknown') AS normalized_region_code, + COALESCE(NULLIF(install_source, ''), 'unknown') AS normalized_install_source, + CASE WHEN tag = 'tidb-labs' THEN 'Lab' ELSE 'Non-Lab' END AS traffic_source + FROM normalized +), scoped AS ( + SELECT * FROM prepared + WHERE exit_code <> 0 + [[AND received_at >= {{start_date}}]] + [[AND received_at < DATE_ADD({{end_date}}, INTERVAL 1 DAY)]] + [[AND region_code = {{region_code}}]] + [[AND cli_version = {{cli_version}}]] + [[AND display_command = {{error_command}}]] + [[AND normalized_error_code = {{error_code}}]] + [[AND ( + {{lab_scope}} = 'with_lab' + OR ({{lab_scope}} = 'without_lab' AND tag <> 'tidb-labs') + OR ({{lab_scope}} = 'lab_only' AND tag = 'tidb-labs') + )]] +) +SELECT + display_command AS `Command`, + normalized_error_code AS `Error Code`, + cli_version AS `CLI Version`, + normalized_region_code AS `Region Code`, + os AS `Operating System`, + arch AS `Architecture`, + normalized_install_source AS `Install Source`, + traffic_source AS `Traffic Source`, + COUNT(*) AS `Failures`, + COUNT(DISTINCT anonymous_installation_id) AS `Affected Installations`, + MIN(received_at) AS `First Seen`, + MAX(received_at) AS `Last Seen` +FROM scoped +GROUP BY + display_command, + normalized_error_code, + cli_version, + normalized_region_code, + os, + arch, + normalized_install_source, + traffic_source +ORDER BY `Failures` DESC, `Last Seen` DESC +LIMIT 200; + +-- CARD 15: Error drill-down daily trend +-- Visualization: Combo chart or line chart. Use Activity Date as the X-axis, Failures +-- as bars, and Affected Installations as a line. Use the same Error Details dashboard +-- filters and Card 5 click mappings as card 14. +WITH normalized AS ( + SELECT + e.*, + CASE + WHEN command_path = 'tdc' THEN 'ti' + WHEN command_path LIKE 'tdc %' THEN CONCAT('ti', SUBSTRING(command_path, 4)) + ELSE command_path + END AS canonical_command_path + FROM `tdc_telemetry`.`telemetry_events` e +), prepared AS ( + SELECT + normalized.*, + CASE + WHEN canonical_command_path LIKE 'ti %' THEN SUBSTRING(canonical_command_path, 4) + ELSE canonical_command_path + END AS display_command, + COALESCE(NULLIF(error_code, ''), 'unclassified') AS normalized_error_code + FROM normalized +), scoped AS ( + SELECT * FROM prepared + WHERE exit_code <> 0 + [[AND received_at >= {{start_date}}]] + [[AND received_at < DATE_ADD({{end_date}}, INTERVAL 1 DAY)]] + [[AND region_code = {{region_code}}]] + [[AND cli_version = {{cli_version}}]] + [[AND display_command = {{error_command}}]] + [[AND normalized_error_code = {{error_code}}]] + [[AND ( + {{lab_scope}} = 'with_lab' + OR ({{lab_scope}} = 'without_lab' AND tag <> 'tidb-labs') + OR ({{lab_scope}} = 'lab_only' AND tag = 'tidb-labs') + )]] +) +SELECT + DATE(received_at) AS `Activity Date`, + COUNT(*) AS `Failures`, + COUNT(DISTINCT anonymous_installation_id) AS `Affected Installations` +FROM scoped +GROUP BY DATE(received_at) +ORDER BY `Activity Date`; + +-- CARD 16: Individual error events +-- Visualization: Table sorted by Event Time descending. This is one row per telemetry +-- error event. It intentionally shows a derived installation fingerprint instead of +-- the stored installation ID. Telemetry does not collect raw terminal errors, SQL, +-- paths, payloads, credentials, or command output, so those values cannot appear here. +WITH normalized AS ( + SELECT + e.*, + CASE + WHEN command_path = 'tdc' THEN 'ti' + WHEN command_path LIKE 'tdc %' THEN CONCAT('ti', SUBSTRING(command_path, 4)) + ELSE command_path + END AS canonical_command_path + FROM `tdc_telemetry`.`telemetry_events` e +), prepared AS ( + SELECT + normalized.*, + CASE + WHEN canonical_command_path LIKE 'ti %' THEN SUBSTRING(canonical_command_path, 4) + ELSE canonical_command_path + END AS display_command, + COALESCE(NULLIF(error_code, ''), 'unclassified') AS normalized_error_code, + COALESCE(NULLIF(region_code, ''), 'unknown') AS normalized_region_code, + COALESCE(NULLIF(install_source, ''), 'unknown') AS normalized_install_source, + CASE WHEN tag = 'tidb-labs' THEN 'Lab' ELSE 'Non-Lab' END AS traffic_source + FROM normalized +), scoped AS ( + SELECT * FROM prepared + WHERE exit_code <> 0 + [[AND received_at >= {{start_date}}]] + [[AND received_at < DATE_ADD({{end_date}}, INTERVAL 1 DAY)]] + [[AND region_code = {{region_code}}]] + [[AND cli_version = {{cli_version}}]] + [[AND display_command = {{error_command}}]] + [[AND normalized_error_code = {{error_code}}]] + [[AND ( + {{lab_scope}} = 'with_lab' + OR ({{lab_scope}} = 'without_lab' AND tag <> 'tidb-labs') + OR ({{lab_scope}} = 'lab_only' AND tag = 'tidb-labs') + )]] +) +SELECT + received_at AS `Event Time`, + event_id AS `Event ID`, + LEFT(SHA2(anonymous_installation_id, 256), 12) AS `Installation Fingerprint`, + display_command AS `Command`, + normalized_error_code AS `Error Code`, + exit_code AS `Exit Code`, + duration_ms AS `Duration (ms)`, + flag_names_json AS `Flag Names`, + cli_version AS `CLI Version`, + normalized_region_code AS `Region Code`, + os AS `Operating System`, + arch AS `Architecture`, + normalized_install_source AS `Install Source`, + traffic_source AS `Traffic Source`, + NULLIF(tag, '') AS `Telemetry Tag`, + extra_json AS `Telemetry Metadata` +FROM scoped +ORDER BY received_at DESC +LIMIT 500; diff --git a/docs/telemetry-backend-design.md b/docs/telemetry-backend-design.md index 0e122e3..7963897 100644 --- a/docs/telemetry-backend-design.md +++ b/docs/telemetry-backend-design.md @@ -2,26 +2,24 @@ ## Purpose -The ti telemetry backend is a small product-owned HTTPS ingestion service between the ti CLI, TiDB, and PostHog. +The ti telemetry backend is a small product-owned HTTPS ingestion service between the ti CLI and TiDB. ```text -ti CLI -> telemetry backend -> in-memory batcher - |-> TiDB - |-> PostHog /batch/ +ti CLI -> telemetry backend -> in-memory batcher -> TiDB ``` -The backend exists so the CLI never sends directly to PostHog, never embeds a PostHog project token, and never relies on PostHog as the only telemetry data store. The backend enforces the privacy schema, rate limits abuse, rejects unknown fields, batches valid events in memory, then best-effort writes the same sanitized batch to TiDB and PostHog. +The backend exists so the CLI sends only to a product-owned endpoint and never embeds storage credentials. The backend enforces the privacy schema, rate limits abuse, rejects unknown fields, batches valid events in memory, then best-effort writes each sanitized batch to TiDB. -TiDB is the ti-owned telemetry store and future migration/analysis base. PostHog is an analytics destination. TiDB is not an outbox queue in MVP, and the backend must not consume events from TiDB to forward them to PostHog. +TiDB is the only telemetry store and the analysis base. The backend does not forward telemetry to another analytics destination. ## Non-goals - Do not store raw telemetry request bodies. - Do not capture command output, API payloads, SQL text, file paths, credentials, profile names, cloud resource IDs, or raw error messages. -- Do not identify users, create PostHog person profiles, call PostHog identify, alias, group, or feature flag APIs. +- Do not identify users or create user profiles in an analytics service. - Do not require a CLI-shipped API token. Anything shipped in the CLI is public. -- Do not add MQ, Kafka, SQS, Pub/Sub, durable outbox tables, or TiDB-to-PostHog consumer workflows for MVP. -- Do not add internal worker concurrency for PostHog forwarding. One process owns one in-memory batcher and one flush loop. +- Do not add MQ, Kafka, SQS, Pub/Sub, durable outbox tables, or downstream forwarding workflows for MVP. +- Do not add internal worker concurrency for sink writes. One process owns one in-memory batcher and one flush loop. ## Runtime Configuration @@ -43,11 +41,11 @@ TELEMETRY_RATE_LIMIT_PER_MINUTE=60 TELEMETRY_RATE_LIMIT_BURST=120 TELEMETRY_TRUSTED_PROXY_CIDRS=172.16.0.0/12 TIDB_DSN=tdc_telemetry:password@tcp(gateway01.us-east-1.prod.aws.tidbcloud.com:4000)/tdc_telemetry?tls=true&parseTime=true -POSTHOG_API_HOST=https://us.i.posthog.com -POSTHOG_PROJECT_TOKEN=phc_xxx ``` -`TIDB_DSN` and `POSTHOG_PROJECT_TOKEN` are application credentials. They must remain only in the server-side `.env`, must never be stored in GitHub repository or Environment secrets, must never be committed to git, and must never be logged. GitHub may store only deployment transport credentials such as the SSH host, username, and key. For TiDB Cloud, the DSN must enable TLS with certificate and identity verification. For EU PostHog Cloud, use `https://eu.i.posthog.com`. For self-hosted PostHog, use the ingestion host for that instance. +`TIDB_DSN` is an application credential. It must remain only in the server-side `.env`, must never be stored in GitHub repository or Environment secrets, must never be committed to git, and must never be logged. GitHub may store only deployment transport credentials such as the SSH host, username, and key. For TiDB Cloud, the DSN must enable TLS with certificate and identity verification. `TELEMETRY_ENVIRONMENT=production` enables this strict TLS validation. + +Deployments upgraded from the former dual-sink implementation may still have `POSTHOG_API_HOST` and `POSTHOG_PROJECT_TOKEN` in their server-local `.env`. The backend ignores those variables; operators should remove them after deployment. This change stops future forwarding but does not delete historical data already held by an external service. ## HTTP API @@ -65,15 +63,14 @@ Response: ### `GET /readyz` -Readiness check. This verifies that required environment variables are present, the TiDB connection can be opened, and the service can construct the PostHog batch URL. It does not need to send a test event to PostHog. +Readiness check. This verifies that required environment variables are present and the TiDB connection can be opened. Response: ```json { "ok": true, - "tidb_configured": true, - "posthog_configured": true + "tidb_configured": true } ``` @@ -83,9 +80,9 @@ Prometheus text-format process counters for accepted, rejected, rate-limited, bu ### `POST /v1/telemetry/batch` -Accepts one small request batch of sanitized ti CLI telemetry events, validates it, enqueues valid events into the bounded in-memory batcher, and returns immediately. The response means the backend accepted the events into memory; it does not mean TiDB and PostHog have already flushed the batch. +Accepts one small request batch of sanitized ti CLI telemetry events, validates it, enqueues valid events into the bounded in-memory batcher, and returns immediately. The response means the backend accepted the events into memory; it does not mean TiDB has already flushed the batch. -The only success status for this endpoint is `202 Accepted`. Do not return `200 OK` for an accepted batch because the asynchronous TiDB and PostHog sink writes are not complete. +The only success status for this endpoint is `202 Accepted`. Do not return `200 OK` for an accepted batch because the asynchronous TiDB write is not complete. Required request headers: @@ -216,13 +213,13 @@ Accepted events are appended to a bounded in-memory batcher. The batcher has exa - `TELEMETRY_FLUSH_INTERVAL`, default 5 seconds. - Shutdown drain, capped by `TELEMETRY_SHUTDOWN_DRAIN_TIMEOUT`. -The flush loop writes the same sanitized batch to TiDB and PostHog. These writes are independent best-effort sink writes. A TiDB failure must not prevent the PostHog attempt, and a PostHog failure must not prevent the TiDB attempt. Failures are logged as aggregate operational errors and exported through the private `/metrics` endpoint; they are not reported back to the CLI because the CLI already received `202 Accepted`. +The flush loop writes each sanitized batch to TiDB. Failures are logged as aggregate operational errors and exported through the private `/metrics` endpoint; they are not reported back to the CLI because the CLI already received `202 Accepted`. The batcher may do a small in-memory retry for sink failures, but it must not write retry state to disk and must not replay from TiDB. A process crash can lose accepted-but-unflushed events. That is acceptable for MVP telemetry because the data is best-effort and lossy by design. ## TiDB Storage -TiDB stores sanitized telemetry events as ti-owned telemetry data. It is not a queue for PostHog forwarding in MVP. +TiDB stores sanitized telemetry events as ti-owned telemetry data and is the only persistent telemetry destination. Recommended schema: @@ -280,63 +277,6 @@ The same deployment identity runs the one-shot migrations and the API, so it nee `make telemetry-e2e` is intentionally isolated from this production database. Its ignored `e2e/.env.telemetry` must point to a test-only TiDB identity with `CREATE` and `DROP` database privileges. The test creates a unique `tdc_telemetry_e2e_*` database, validates initial migration and an additive upgrade with a preserved legacy row, verifies a real event write through a local backend, then drops that temporary database. It never queries, deletes, or migrates production telemetry rows. -## PostHog Forwarding - -Forward accepted batches to PostHog's `/batch/` endpoint during the same flush cycle as the TiDB write: - -```http -POST {POSTHOG_API_HOST}/batch/ -Content-Type: application/json -``` - -PostHog request body: - -```json -{ - "api_key": "", - "historical_migration": false, - "batch": [ - { - "event": "ti.command.finished", - "timestamp": "2026-07-08T12:00:00Z", - "properties": { - "distinct_id": "ti_01j0a0n8m9f4q2x6cn0b9q3k3z", - "$process_person_profile": false, - "schema_version": 2, - "event_id": "018f7e67-8fe4-7cc2-9ca5-2d3536c7fb44", - "command_path": "ti fs create-file-system", - "flag_names": ["file-system-name", "output"], - "exit_code": 0, - "error_code": "", - "duration_ms": 182, - "cloud_provider": "aws", - "region_code": "aws-us-east-1", - "cli_version": "0.1.0", - "os": "darwin", - "arch": "arm64", - "install_source": "github-release", - "profile_source": "default", - "ti_environment": "production", - "tag": "e2b-preview", - "extra": {"campaign":"launch","runtime":"e2b"} - } - } - ] -} -``` - -Important: - -- Set `$process_person_profile` to `false` for every event. -- Do not send `$identify`, `$create_alias`, `$groupidentify`, or feature flag events. -- Do not add person properties. -- Do not add IP-derived location fields in the backend. -- Use `anonymous_installation_id` only as `distinct_id`. -- Use `TELEMETRY_SINK_TIMEOUT` for the PostHog request. -- Do not log the full PostHog request body in production. - -PostHog's capture docs state that `/i/v0/e` and `/batch` are the primary event ingestion endpoints, that the API uses a project token, and that API-captured events should set `$process_person_profile: false` to remain anonymous. - ## Rate Limiting And Abuse Controls The endpoint is public because the CLI cannot safely hold a secret. Protect it with cheap server-side controls: @@ -364,14 +304,12 @@ Safe logs: - rate limit decision - batch flush size - TiDB sink success/failure category -- PostHog sink success/failure category - latency bucket Never log: - request body - `TIDB_DSN` -- `POSTHOG_PROJECT_TOKEN` - `anonymous_installation_id` - raw client IP beyond normal reverse proxy access logs, unless required for abuse handling - rejected field values @@ -428,8 +366,6 @@ TELEMETRY_RATE_LIMIT_PER_MINUTE=60 TELEMETRY_RATE_LIMIT_BURST=120 TELEMETRY_TRUSTED_PROXY_CIDRS=172.16.0.0/12 TIDB_DSN=tdc_telemetry:password@tcp(gateway01.us-east-1.prod.aws.tidbcloud.com:4000)/tdc_telemetry?tls=true&parseTime=true -POSTHOG_API_HOST=https://us.i.posthog.com -POSTHOG_PROJECT_TOKEN=phc_xxx ``` The checked-in Compose definition is `deploy/telemetry/docker-compose.yml`. It first runs the non-root one-shot `migrate` service, then starts `api` only after migration completes successfully. Both use the same embedded Go migrations and server-local `.env`. The API has a read-only root filesystem, is exposed only to the private Compose network, and only Caddy publishes ports 80 and 443. The checked-in Caddy configuration does not enable access logging, so client IP addresses are not persisted by default. @@ -460,7 +396,7 @@ Deployment secrets available to the `telemetry-production` job: - `DEPLOY_SSH_KEY` - `DEPLOY_PATH` -These are deployment transport credentials only. Keep `TIDB_DSN`, `POSTHOG_PROJECT_TOKEN`, and all other application credentials exclusively in the server-side `.env`; do not duplicate them in GitHub repository secrets, GitHub Environment secrets, workflow inputs, artifacts, or SSH script arguments. +These are deployment transport credentials only. Keep `TIDB_DSN` and all other application credentials exclusively in the server-side `.env`; do not duplicate them in GitHub repository secrets, GitHub Environment secrets, workflow inputs, artifacts, or SSH script arguments. Example workflow: @@ -550,23 +486,23 @@ Expected response: } ``` -Then verify the event appears in TiDB after the next flush and appears in PostHog as `ti.command.finished` without creating a person profile. +Then verify the event appears in TiDB after the next flush. ## When To Add MQ Or Durable Queues Do not add MQ or durable queues for MVP. Add SQS, Pub/Sub, Redpanda, Kafka, durable outbox tables, or another queue only when at least one of these becomes true: - accepted-but-unflushed event loss becomes unacceptable -- PostHog or TiDB downtime causes unacceptable event loss +- TiDB downtime causes unacceptable event loss - replay/backfill becomes a product requirement - multiple destinations need fan-out with delivery guarantees - strict traffic smoothing is required across multiple backend instances -Until then, in-memory batching plus independent best-effort TiDB/PostHog sink writes is simpler and matches the lossy nature of CLI telemetry. +Until then, in-memory batching plus best-effort TiDB writes is simpler and matches the lossy nature of CLI telemetry. ## Backend Acceptance Checklist -- `POST /v1/telemetry/batch` accepts the documented valid request, enqueues it without synchronously writing TiDB or PostHog, and returns `202 Accepted`. +- `POST /v1/telemetry/batch` accepts the documented valid request, enqueues it without synchronously writing TiDB, and returns `202 Accepted`. - The ingestion endpoint never returns `200 OK` for a successfully enqueued batch. - Unknown fields are rejected. - Disallowed field names are rejected. @@ -576,15 +512,12 @@ Until then, in-memory batching plus independent best-effort TiDB/PostHog sink wr - Full in-memory buffer returns `503` without blocking indefinitely. - Batcher flushes on max events, max bytes, interval, and shutdown drain. - TiDB sink performs batch insert into `telemetry_events` using sanitized fields only. -- PostHog sink sends batches to `/batch/` and sets `$process_person_profile: false`. -- TiDB sink failure does not skip the PostHog sink attempt. -- PostHog sink failure does not skip the TiDB sink attempt. -- No component consumes events from TiDB to forward them to PostHog. -- PostHog token and TiDB DSN are not logged. +- No component forwards accepted events from the batcher or TiDB to a third-party analytics service. +- TiDB DSN is not logged. - Full request bodies are not logged. - Sink failures do not crash the service. - `GET /healthz` and `GET /readyz` work behind Caddy. - Private `GET /metrics` exposes aggregate counters without event values, and Caddy does not expose it publicly. - Docker Compose deploy runs `migrate` successfully before starting `api` and `caddy`. - GitHub Actions SSH deploy can rebuild and restart the service with one manual workflow dispatch after approval through the `telemetry-production` Environment. -- `TIDB_DSN`, `POSTHOG_PROJECT_TOKEN`, and other application credentials exist only in the server-side `.env` and are absent from GitHub secrets, workflow inputs, logs, and artifacts. +- `TIDB_DSN` and other application credentials exist only in the server-side `.env` and are absent from GitHub secrets, workflow inputs, logs, and artifacts. diff --git a/e2e/telemetry_test.go b/e2e/telemetry_test.go index aebc68f..0d010a9 100644 --- a/e2e/telemetry_test.go +++ b/e2e/telemetry_test.go @@ -8,7 +8,6 @@ import ( "errors" "net" "net/http" - "net/http/httptest" "os" "os/exec" "strings" @@ -49,21 +48,7 @@ flag_names_json, exit_code, duration_ms, cli_version, os, arch, schema_version runTelemetryMigrator(t, ctx, migrator, databaseDSN) assertLegacyEventSurvivesMigration(t, ctx, testDB) - postHogRequests := make(chan struct{}, 1) - postHog := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - if request.Method != http.MethodPost || request.URL.Path != "/batch/" { - http.NotFound(writer, request) - return - } - select { - case postHogRequests <- struct{}{}: - default: - } - writer.WriteHeader(http.StatusOK) - })) - t.Cleanup(postHog.Close) - - endpoint := startTelemetryBackend(t, ctx, databaseDSN, postHog.URL) + endpoint := startTelemetryBackend(t, ctx, databaseDSN) tag := "telemetry-e2e-" + databaseName runID := "run-" + tag result := runTIWithInput(t, tiBinary(t), "", append(tiConfigEnv(), @@ -77,11 +62,6 @@ flag_names_json, exit_code, duration_ms, cli_version, os, arch, schema_version result.wantExitCode(0) assertTelemetryEventStored(t, ctx, testDB, tag, runID) - select { - case <-postHogRequests: - case <-time.After(10 * time.Second): - t.Fatal("local PostHog receiver did not receive the telemetry batch") - } } func createTelemetryE2EDatabase(t *testing.T, ctx context.Context, baseDSN string) (string, string) { @@ -150,7 +130,7 @@ func assertLegacyEventSurvivesMigration(t *testing.T, ctx context.Context, db *s } } -func startTelemetryBackend(t *testing.T, ctx context.Context, dsn, postHogURL string) string { +func startTelemetryBackend(t *testing.T, ctx context.Context, dsn string) string { t.Helper() binary := strings.TrimSpace(os.Getenv("TI_TELEMETRY_BACKEND_E2E_BIN")) if binary == "" { @@ -170,8 +150,6 @@ func startTelemetryBackend(t *testing.T, ctx context.Context, dsn, postHogURL st command.Stderr = &stderr command.Env = append(os.Environ(), "TIDB_DSN="+dsn, - "POSTHOG_API_HOST="+postHogURL, - "POSTHOG_PROJECT_TOKEN=telemetry-e2e", "TELEMETRY_BIND_ADDR="+address, "TELEMETRY_PUBLIC_HOST=telemetry-e2e.local", "TELEMETRY_ENVIRONMENT=telemetry-e2e", diff --git a/internal/api/client.go b/internal/api/client.go index ef3734a..278ce9f 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -302,7 +302,8 @@ func retryableRequest(req *http.Request) bool { func (c *Client) statusError(req *http.Request, res *http.Response) error { body, _ := io.ReadAll(io.LimitReader(res.Body, 64*1024)) body = []byte(c.Redactor.Redact(string(body))) - apiMessage := responseMessage(body) + remoteError := parseRemoteError(body) + apiMessage := remoteError.Message requestID := responseRequestID(res.Header) switch res.StatusCode { case http.StatusBadRequest: @@ -392,13 +393,16 @@ func (c *Client) statusError(req *http.Request, res *http.Response) error { } case http.StatusPaymentRequired: return &Error{ - Code: "api.payment_required", - Category: "api", - ExitCode: 1, - StatusCode: res.StatusCode, - RequestID: requestID, - Message: "payment required: " + messageOrDefault(apiMessage, "the remote service rejected the request because payment could not be processed"), - Body: string(body), + Code: "api.payment_required", + Category: "api", + ExitCode: 1, + StatusCode: res.StatusCode, + RequestID: requestID, + Message: "payment required: " + messageOrDefault(apiMessage, "the remote service rejected the request because payment could not be processed"), + Body: string(body), + RemoteCode: remoteError.Code, + RemoteDetails: remoteError.Details, + RemoteActionType: remoteError.ActionType, } case http.StatusTooManyRequests: return &Error{ @@ -443,22 +447,39 @@ func (c *Client) resolveURL(requestPath string) (string, error) { return base.String(), nil } -func responseMessage(body []byte) string { +type remoteErrorResponse struct { + Message string + Code string + Details []byte + ActionType string +} + +func parseRemoteError(body []byte) remoteErrorResponse { if len(body) == 0 { - return "" + return remoteErrorResponse{} } var payload struct { - Message string `json:"message"` - Error string `json:"error"` - Code any `json:"code"` + Message string `json:"message"` + Error string `json:"error"` + Code json.RawMessage `json:"code"` + Details json.RawMessage `json:"details"` + Action json.RawMessage `json:"action"` } if err := json.Unmarshal(body, &payload); err != nil { - return "" + return remoteErrorResponse{} + } + result := remoteErrorResponse{Message: payload.Message, Details: payload.Details} + if result.Message == "" { + result.Message = payload.Error + } + _ = json.Unmarshal(payload.Code, &result.Code) + var action struct { + Type string `json:"type"` } - if payload.Message != "" { - return payload.Message + if json.Unmarshal(payload.Action, &action) == nil { + result.ActionType = action.Type } - return payload.Error + return result } func messageOrDefault(message, fallback string) string { diff --git a/internal/api/client_test.go b/internal/api/client_test.go index afb7f38..1b7d9c8 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -87,7 +87,12 @@ func TestClientMapsAPIGap(t *testing.T) { } func TestClientMapsPaymentRequired(t *testing.T) { - err := statusError(t, http.StatusPaymentRequired, `{"message":"payment cannot be processed"}`, authz.StarterClusterCreate) + err := statusError(t, http.StatusPaymentRequired, `{ + "error":"free TiDB Cloud tenant limit reached", + "code":"free_tenant_limit_reached", + "details":{"tenant_count":1,"tenant_limit":1}, + "action":{"type":"add_payment_method"} + }`, authz.StarterClusterCreate) var apiErr *Error if !errors.As(err, &apiErr) { t.Fatalf("expected api.Error, got %T", err) @@ -98,6 +103,15 @@ func TestClientMapsPaymentRequired(t *testing.T) { if !strings.Contains(apperr.MessageFor(err), "payment required") { t.Fatalf("unexpected message %q", apperr.MessageFor(err)) } + if apiErr.RemoteCode != "free_tenant_limit_reached" { + t.Fatalf("remote code = %q", apiErr.RemoteCode) + } + if apiErr.RemoteActionType != "add_payment_method" { + t.Fatalf("remote action = %q", apiErr.RemoteActionType) + } + if !strings.Contains(string(apiErr.RemoteDetails), `"tenant_count":1`) || !strings.Contains(string(apiErr.RemoteDetails), `"tenant_limit":1`) { + t.Fatalf("remote details = %s", apiErr.RemoteDetails) + } } func TestClientRecordsSafeAPIEvent(t *testing.T) { diff --git a/internal/api/error.go b/internal/api/error.go index 61f1e55..1634a6c 100644 --- a/internal/api/error.go +++ b/internal/api/error.go @@ -7,14 +7,17 @@ import ( ) type Error struct { - Code string - Category string - ExitCode int - StatusCode int - RequestID string - Message string - Body string - Cause error + Code string + Category string + ExitCode int + StatusCode int + RequestID string + Message string + Body string + RemoteCode string + RemoteDetails []byte + RemoteActionType string + Cause error } func (e *Error) Error() string { diff --git a/internal/fs/tenant_control.go b/internal/fs/tenant_control.go index 686a7ad..a0453b1 100644 --- a/internal/fs/tenant_control.go +++ b/internal/fs/tenant_control.go @@ -2,6 +2,7 @@ package fs import ( "context" + "encoding/json" "errors" "fmt" "net/http" @@ -31,6 +32,7 @@ const ( invalidDisplayFilterMessage = "--display-name filter cannot be empty or contain %, _, or control characters" invalidLabelKeyMessage = "label keys must be Kubernetes qualified names: an optional lowercase DNS prefix of at most 253 bytes and '/', followed by 1-63 bytes using ASCII letters, numbers, '-', '_' or '.', starting and ending with a letter or number" invalidLabelValueMessage = "label values must be empty or at most 63 bytes using ASCII letters, numbers, '-', '_' or '.', and must start and end with a letter or number" + tiDBCloudBillingPaymentsURL = "https://tidbcloud.com/org-settings/billing/payments" ) var ( @@ -451,6 +453,9 @@ func mapAdminTenantError(err error, operation, fileSystemID, regionCode string) if !errors.As(err, &apiErr) { return err } + if operation == "create" && apiErr.StatusCode == http.StatusPaymentRequired && apiErr.RemoteActionType == "add_payment_method" && isFreeTierPaymentCode(apiErr.RemoteCode) { + return apperr.Wrap("fs.payment_method_required", "api", 1, fileSystemPaymentMethodMessage(apiErr), err) + } if apiErr.StatusCode == http.StatusConflict && operation == "create" { return apperr.Wrap("fs.display_name_conflict", "api", 1, "display name conflicts with an existing file system in the organization", err) } @@ -467,3 +472,27 @@ func mapAdminTenantError(err error, operation, fileSystemID, regionCode string) } return err } + +func isFreeTierPaymentCode(code string) bool { + switch code { + case "free_tenant_limit_reached", "free_quota_exceeded", "free_spending_limit_forbidden", "free_quota_mutation_forbidden", "free_tenant_pool_forbidden": + return true + default: + return false + } +} + +func fileSystemPaymentMethodMessage(apiErr *api.Error) string { + message := "the free TiDB Cloud plan does not allow another Filesystem" + if apiErr.RemoteCode == "free_tenant_limit_reached" { + message = "free TiDB Cloud Filesystem limit reached" + var details struct { + TenantCount *int `json:"tenant_count"` + TenantLimit *int `json:"tenant_limit"` + } + if json.Unmarshal(apiErr.RemoteDetails, &details) == nil && details.TenantCount != nil && details.TenantLimit != nil && *details.TenantCount >= 0 && *details.TenantLimit >= 0 { + message += fmt.Sprintf(" (%d of %d used)", *details.TenantCount, *details.TenantLimit) + } + } + return fmt.Sprintf("%s. Add a payment method to create more Filesystems: %s", message, tiDBCloudBillingPaymentsURL) +} diff --git a/internal/fs/tenant_control_test.go b/internal/fs/tenant_control_test.go index be46015..b7f0f6f 100644 --- a/internal/fs/tenant_control_test.go +++ b/internal/fs/tenant_control_test.go @@ -360,6 +360,78 @@ func TestTenantControlMapsErrorsAndRejectsContractViolations(t *testing.T) { } } +func TestTenantControlCreateMapsFreeTierUpgradeGuidance(t *testing.T) { + tests := []struct { + name string + body string + wantCode string + wantMessageParts []string + rejectMessagePart string + }{ + { + name: "limit with usage details", + body: `{ + "error":"free TiDB Cloud tenant limit reached", + "code":"free_tenant_limit_reached", + "details":{"tenant_count":1,"tenant_limit":1}, + "action":{"type":"add_payment_method"} + }`, + wantCode: "fs.payment_method_required", + wantMessageParts: []string{ + "free TiDB Cloud Filesystem limit reached (1 of 1 used)", + "https://tidbcloud.com/org-settings/billing/payments", + }, + }, + { + name: "upgrade action without optional details", + body: `{ + "error":"free tenant pool creation is not available", + "code":"free_tenant_pool_forbidden", + "action":{"type":"add_payment_method"} + }`, + wantCode: "fs.payment_method_required", + wantMessageParts: []string{ + "free TiDB Cloud plan does not allow another Filesystem", + "https://tidbcloud.com/org-settings/billing/payments", + }, + rejectMessagePart: "0 of 0", + }, + { + name: "generic payment error remains generic", + body: `{"error":"payment could not be processed","code":"payment_required"}`, + wantCode: "api.payment_required", + wantMessageParts: []string{ + "payment required", + }, + rejectMessagePart: "https://tidbcloud.com/org-settings/billing/payments", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusPaymentRequired) + _, _ = w.Write([]byte(test.body)) + })) + defer server.Close() + + _, err := directTenantService(t.TempDir(), server.URL).CreateFileSystem(context.Background(), CreateFileSystemOptions{Profile: testProfile()}) + if got := apperr.CodeFor(err); got != test.wantCode { + t.Fatalf("code = %q, want %q; error = %v", got, test.wantCode, err) + } + message := apperr.MessageFor(err) + for _, part := range test.wantMessageParts { + if !strings.Contains(message, part) { + t.Fatalf("message = %q, want %q", message, part) + } + } + if test.rejectMessagePart != "" && strings.Contains(message, test.rejectMessagePart) { + t.Fatalf("message = %q, must not contain %q", message, test.rejectMessagePart) + } + }) + } +} + func TestTenantControlDeleteFailurePreservesCredential(t *testing.T) { home := t.TempDir() profile := testProfile() diff --git a/internal/telemetrybackend/batcher.go b/internal/telemetrybackend/batcher.go index 09a94cb..df097d3 100644 --- a/internal/telemetrybackend/batcher.go +++ b/internal/telemetrybackend/batcher.go @@ -22,12 +22,8 @@ type Metrics struct { RateLimited atomic.Uint64 DroppedEvents atomic.Uint64 FlushedEvents atomic.Uint64 - SinkSuccesses atomic.Uint64 - SinkFailures atomic.Uint64 TiDBSuccesses atomic.Uint64 TiDBFailures atomic.Uint64 - PostHogSuccesses atomic.Uint64 - PostHogFailures atomic.Uint64 } type queuedEvent struct { @@ -190,7 +186,6 @@ func (b *Batcher) flushNext(parent context.Context) { err := sink.Write(ctx, batch) cancel() if err != nil { - b.metrics.SinkFailures.Add(1) b.recordSinkResult(sink.Name(), false) b.logger.Error( "telemetry sink write failed", @@ -200,7 +195,6 @@ func (b *Batcher) flushNext(parent context.Context) { ) continue } - b.metrics.SinkSuccesses.Add(1) b.recordSinkResult(sink.Name(), true) b.logger.Info( "telemetry sink write completed", @@ -220,12 +214,6 @@ func (b *Batcher) recordSinkResult(name string, success bool) { } else { b.metrics.TiDBFailures.Add(1) } - case "posthog": - if success { - b.metrics.PostHogSuccesses.Add(1) - } else { - b.metrics.PostHogFailures.Add(1) - } } } diff --git a/internal/telemetrybackend/batcher_test.go b/internal/telemetrybackend/batcher_test.go index 6de0e54..bf3a3cd 100644 --- a/internal/telemetrybackend/batcher_test.go +++ b/internal/telemetrybackend/batcher_test.go @@ -6,46 +6,46 @@ import ( "time" ) -func TestBatcherFlushesAtEventThresholdAndAttemptsIndependentSinks(t *testing.T) { +func TestBatcherFlushesAtEventThreshold(t *testing.T) { cfg := testConfig() cfg.FlushMaxEvents = 2 - failing := newRecordingSink("tidb", errTestSink) - succeeding := newRecordingSink("posthog", nil) + sink := newRecordingSink("tidb", nil) metrics := &Metrics{} - batcher := NewBatcher(cfg, []Sink{failing, succeeding}, discardLogger(), metrics) + batcher := NewBatcher(cfg, []Sink{sink}, discardLogger(), metrics) batcher.Start() defer closeBatcher(t, batcher) if !batcher.Enqueue([]Event{testEvent(), testEvent()}) { t.Fatal("Enqueue returned false") } - waitForSink(t, failing) - waitForSink(t, succeeding) - if failing.eventCount() != 2 || succeeding.eventCount() != 2 { - t.Fatalf("sink event counts = %d, %d", failing.eventCount(), succeeding.eventCount()) + waitForSink(t, sink) + if sink.eventCount() != 2 { + t.Fatalf("sink event count = %d, want 2", sink.eventCount()) } - if metrics.SinkFailures.Load() != 1 || metrics.SinkSuccesses.Load() != 1 { - t.Fatalf("sink metrics = failures %d, successes %d", metrics.SinkFailures.Load(), metrics.SinkSuccesses.Load()) + if metrics.TiDBSuccesses.Load() != 1 || metrics.TiDBFailures.Load() != 0 { + t.Fatalf("TiDB metrics = failures %d, successes %d", metrics.TiDBFailures.Load(), metrics.TiDBSuccesses.Load()) } } -func TestBatcherAttemptsTiDBWhenPostHogFails(t *testing.T) { +func TestBatcherRecordsTiDBFailure(t *testing.T) { cfg := testConfig() cfg.FlushMaxEvents = 1 - posthog := newRecordingSink("posthog", errTestSink) - tidb := newRecordingSink("tidb", nil) - batcher := NewBatcher(cfg, []Sink{posthog, tidb}, discardLogger(), nil) + tidb := newRecordingSink("tidb", errTestSink) + metrics := &Metrics{} + batcher := NewBatcher(cfg, []Sink{tidb}, discardLogger(), metrics) batcher.Start() defer closeBatcher(t, batcher) if !batcher.Enqueue([]Event{testEvent()}) { t.Fatal("Enqueue returned false") } - waitForSink(t, posthog) waitForSink(t, tidb) if tidb.eventCount() != 1 { t.Fatalf("TiDB event count = %d, want 1", tidb.eventCount()) } + if metrics.TiDBFailures.Load() != 1 || metrics.TiDBSuccesses.Load() != 0 { + t.Fatalf("TiDB metrics = failures %d, successes %d", metrics.TiDBFailures.Load(), metrics.TiDBSuccesses.Load()) + } } func TestBatcherFlushesAtByteThreshold(t *testing.T) { diff --git a/internal/telemetrybackend/config.go b/internal/telemetrybackend/config.go index 9b45886..bb42a65 100644 --- a/internal/telemetrybackend/config.go +++ b/internal/telemetrybackend/config.go @@ -3,7 +3,6 @@ package telemetrybackend import ( "fmt" "net/netip" - "net/url" "strconv" "strings" "time" @@ -42,8 +41,6 @@ type Config struct { RateLimitBurst int TrustedProxyCIDRs []netip.Prefix TiDBDSN string - PostHogAPIHost string - PostHogProjectToken string } // LoadConfig reads configuration through getenv. Docker Compose supplies these @@ -64,8 +61,6 @@ func LoadConfig(getenv func(string) string) (Config, error) { RateLimitPerMinute: defaultRateLimitPerMinute, RateLimitBurst: defaultRateLimitBurst, TiDBDSN: strings.TrimSpace(getenv("TIDB_DSN")), - PostHogAPIHost: valueOrDefault(getenv("POSTHOG_API_HOST"), "https://us.i.posthog.com"), - PostHogProjectToken: strings.TrimSpace(getenv("POSTHOG_PROJECT_TOKEN")), } var err error @@ -153,16 +148,6 @@ func (c Config) Validate() error { return fmt.Errorf("TIDB_DSN must enable verified TLS in production") } } - if c.PostHogProjectToken == "" { - return fmt.Errorf("POSTHOG_PROJECT_TOKEN is required") - } - u, err := url.Parse(c.PostHogAPIHost) - if err != nil || u.Host == "" || (u.Scheme != "https" && u.Scheme != "http") { - return fmt.Errorf("POSTHOG_API_HOST must be an absolute HTTP URL") - } - if strings.EqualFold(c.Environment, "production") && u.Scheme != "https" { - return fmt.Errorf("POSTHOG_API_HOST must use HTTPS in production") - } return nil } diff --git a/internal/telemetrybackend/config_test.go b/internal/telemetrybackend/config_test.go index ce23d33..0c41040 100644 --- a/internal/telemetrybackend/config_test.go +++ b/internal/telemetrybackend/config_test.go @@ -14,8 +14,6 @@ func TestLoadConfigDefaultsAndOverrides(t *testing.T) { "TELEMETRY_MAX_EVENTS_PER_REQUEST": "12", "TELEMETRY_TRUSTED_PROXY_CIDRS": "10.0.0.0/8, 127.0.0.1/32", "TIDB_DSN": "user:password@tcp(localhost:4000)/telemetry", - "POSTHOG_API_HOST": "http://localhost:8000", - "POSTHOG_PROJECT_TOKEN": "phc_test", } cfg, err := LoadConfig(func(key string) string { return values[key] }) if err != nil { @@ -39,7 +37,6 @@ func TestLoadConfigRejectsProductionWithoutVerifiedTLS(t *testing.T) { values := map[string]string{ "TELEMETRY_PUBLIC_HOST": "telemetry.example.com", "TIDB_DSN": "user:password@tcp(localhost:4000)/telemetry?tls=skip-verify", - "POSTHOG_PROJECT_TOKEN": "phc_test", } _, err := LoadConfig(func(key string) string { return values[key] }) if err == nil || !strings.Contains(err.Error(), "verified TLS") { @@ -54,7 +51,6 @@ func TestLoadConfigRejectsInvalidAndInconsistentLimits(t *testing.T) { "TELEMETRY_BUFFER_MAX_EVENTS": "10", "TELEMETRY_FLUSH_MAX_EVENTS": "11", "TIDB_DSN": "user:password@tcp(localhost:4000)/telemetry", - "POSTHOG_PROJECT_TOKEN": "phc_test", } _, err := LoadConfig(func(key string) string { return values[key] }) if err == nil || !strings.Contains(err.Error(), "cannot exceed") { diff --git a/internal/telemetrybackend/posthog.go b/internal/telemetrybackend/posthog.go deleted file mode 100644 index c326572..0000000 --- a/internal/telemetrybackend/posthog.go +++ /dev/null @@ -1,140 +0,0 @@ -package telemetrybackend - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "strings" -) - -type PostHogSink struct { - batchURL string - projectToken string - environment string - client *http.Client -} - -type postHogBatch struct { - APIKey string `json:"api_key"` - HistoricalMigration bool `json:"historical_migration"` - Batch []postHogEvent `json:"batch"` -} - -type postHogEvent struct { - Event string `json:"event"` - Timestamp string `json:"timestamp"` - Properties postHogProperties `json:"properties"` -} - -type postHogProperties struct { - DistinctID string `json:"distinct_id"` - ProcessPersonProfile bool `json:"$process_person_profile"` - SchemaVersion int `json:"schema_version"` - EventID string `json:"event_id"` - CommandPath string `json:"command_path"` - FlagNames []string `json:"flag_names"` - ExitCode int `json:"exit_code"` - ErrorCode string `json:"error_code"` - DurationMS int64 `json:"duration_ms"` - CloudProvider string `json:"cloud_provider"` - RegionCode string `json:"region_code"` - CLIVersion string `json:"cli_version"` - OS string `json:"os"` - Arch string `json:"arch"` - InstallSource string `json:"install_source"` - ProfileSource string `json:"profile_source"` - TIEnvironment string `json:"ti_environment"` - Tag string `json:"tag,omitempty"` - Extra json.RawMessage `json:"extra,omitempty"` -} - -func NewPostHogSink(apiHost, projectToken, environment string, client *http.Client) (*PostHogSink, error) { - u, err := url.Parse(apiHost) - if err != nil || u.Scheme == "" || u.Host == "" { - return nil, fmt.Errorf("invalid PostHog API host") - } - u.Path = strings.TrimRight(u.Path, "/") + "/batch/" - u.RawQuery = "" - u.Fragment = "" - if client == nil { - client = http.DefaultClient - } - return &PostHogSink{ - batchURL: u.String(), - projectToken: projectToken, - environment: environment, - client: client, - }, nil -} - -func (s *PostHogSink) Name() string { - return "posthog" -} - -func (s *PostHogSink) Ready(context.Context) error { - u, err := url.Parse(s.batchURL) - if err != nil || u.Scheme == "" || u.Host == "" || s.projectToken == "" { - return fmt.Errorf("PostHog sink is not configured") - } - return nil -} - -func (s *PostHogSink) Write(ctx context.Context, events []Event) error { - payload := postHogBatch{ - APIKey: s.projectToken, - HistoricalMigration: false, - Batch: make([]postHogEvent, 0, len(events)), - } - for _, event := range events { - payload.Batch = append(payload.Batch, postHogEvent{ - Event: event.EventName, - Timestamp: event.OccurredAt.Format(timeRFC3339Nano), - Properties: postHogProperties{ - DistinctID: event.AnonymousInstallationID, - ProcessPersonProfile: false, - SchemaVersion: event.SchemaVersion, - EventID: event.EventID, - CommandPath: event.CommandPath, - FlagNames: append([]string(nil), event.FlagNames...), - ExitCode: event.ExitCode, - ErrorCode: event.ErrorCode, - DurationMS: event.DurationMS, - CloudProvider: event.CloudProvider, - RegionCode: event.RegionCode, - CLIVersion: event.CLIVersion, - OS: event.OS, - Arch: event.Arch, - InstallSource: event.InstallSource, - ProfileSource: event.ProfileSource, - TIEnvironment: s.environment, - Tag: event.Tag, - Extra: event.Extra, - }, - }) - } - encoded, err := json.Marshal(payload) - if err != nil { - return fmt.Errorf("encode PostHog batch: %w", err) - } - request, err := http.NewRequestWithContext(ctx, http.MethodPost, s.batchURL, bytes.NewReader(encoded)) - if err != nil { - return fmt.Errorf("create PostHog request: %w", err) - } - request.Header.Set("Content-Type", "application/json") - response, err := s.client.Do(request) - if err != nil { - return fmt.Errorf("send PostHog batch: %w", err) - } - defer response.Body.Close() - _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096)) - if response.StatusCode < 200 || response.StatusCode >= 300 { - return fmt.Errorf("PostHog returned status %d", response.StatusCode) - } - return nil -} - -const timeRFC3339Nano = "2006-01-02T15:04:05.999999999Z07:00" diff --git a/internal/telemetrybackend/posthog_test.go b/internal/telemetrybackend/posthog_test.go deleted file mode 100644 index 6981aec..0000000 --- a/internal/telemetrybackend/posthog_test.go +++ /dev/null @@ -1,72 +0,0 @@ -package telemetrybackend - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" -) - -func TestPostHogSinkUsesBatchEndpointAndDisablesPersonProfiles(t *testing.T) { - var requestPath string - var payload map[string]any - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - requestPath = request.URL.Path - if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { - t.Errorf("decode payload: %v", err) - } - writer.WriteHeader(http.StatusOK) - })) - defer server.Close() - - sink, err := NewPostHogSink(server.URL, "phc_secret", "test", server.Client()) - if err != nil { - t.Fatal(err) - } - event := testEvent() - event.SchemaVersion = eventSchemaVersionV2 - event.Tag = "e2b-preview" - event.Extra = json.RawMessage(`{"campaign":"launch"}`) - if err := sink.Write(context.Background(), []Event{event}); err != nil { - t.Fatalf("Write returned error: %v", err) - } - if requestPath != "/batch/" { - t.Fatalf("request path = %q, want /batch/", requestPath) - } - if payload["api_key"] != "phc_secret" { - t.Fatalf("api_key = %#v", payload["api_key"]) - } - batch := payload["batch"].([]any) - properties := batch[0].(map[string]any)["properties"].(map[string]any) - if properties["$process_person_profile"] != false { - t.Fatalf("$process_person_profile = %#v", properties["$process_person_profile"]) - } - if properties["tag"] != "e2b-preview" { - t.Fatalf("tag = %#v", properties["tag"]) - } - if extra, ok := properties["extra"].(map[string]any); !ok || extra["campaign"] != "launch" { - t.Fatalf("extra = %#v", properties["extra"]) - } - for _, prohibited := range []string{"sql", "path", "password", "token", "profile_name", "cluster_id"} { - if _, exists := properties[prohibited]; exists { - t.Fatalf("PostHog properties include prohibited field %q", prohibited) - } - } -} - -func TestPostHogSinkReturnsGenericStatusError(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { - http.Error(writer, "sensitive upstream body", http.StatusUnauthorized) - })) - defer server.Close() - sink, _ := NewPostHogSink(server.URL, "phc_secret", "test", server.Client()) - err := sink.Write(context.Background(), []Event{testEvent()}) - if err == nil || !strings.Contains(err.Error(), "status 401") { - t.Fatalf("Write error = %v", err) - } - if strings.Contains(err.Error(), "sensitive upstream body") { - t.Fatalf("Write exposed response body: %v", err) - } -} diff --git a/internal/telemetrybackend/server.go b/internal/telemetrybackend/server.go index 895a617..b8e147d 100644 --- a/internal/telemetrybackend/server.go +++ b/internal/telemetrybackend/server.go @@ -22,7 +22,6 @@ type Server struct { config Config batcher *Batcher tidb readinessCheck - posthog readinessCheck limiter *ipRateLimiter logger *slog.Logger metrics *Metrics @@ -33,7 +32,6 @@ func NewServer( config Config, batcher *Batcher, tidb readinessCheck, - posthog readinessCheck, logger *slog.Logger, metrics *Metrics, ) *Server { @@ -47,7 +45,6 @@ func NewServer( config: config, batcher: batcher, tidb: tidb, - posthog: posthog, limiter: newIPRateLimiter(config.RateLimitPerMinute, config.RateLimitBurst), logger: logger, metrics: metrics, @@ -80,9 +77,7 @@ func (s *Server) handleMetrics(writer http.ResponseWriter, request *http.Request "telemetry_buffer_dropped_total %d\n"+ "telemetry_flush_events_total %d\n"+ "telemetry_sink_total{sink=\"tidb\",result=\"success\"} %d\n"+ - "telemetry_sink_total{sink=\"tidb\",result=\"failure\"} %d\n"+ - "telemetry_sink_total{sink=\"posthog\",result=\"success\"} %d\n"+ - "telemetry_sink_total{sink=\"posthog\",result=\"failure\"} %d\n", + "telemetry_sink_total{sink=\"tidb\",result=\"failure\"} %d\n", s.metrics.AcceptedEvents.Load(), s.metrics.RejectedRequests.Load(), s.metrics.RateLimited.Load(), @@ -91,8 +86,6 @@ func (s *Server) handleMetrics(writer http.ResponseWriter, request *http.Request s.metrics.FlushedEvents.Load(), s.metrics.TiDBSuccesses.Load(), s.metrics.TiDBFailures.Load(), - s.metrics.PostHogSuccesses.Load(), - s.metrics.PostHogFailures.Load(), ) } @@ -112,15 +105,13 @@ func (s *Server) handleReady(writer http.ResponseWriter, request *http.Request) ctx, cancel := context.WithTimeout(request.Context(), s.config.SinkTimeout) defer cancel() tidbReady := s.tidb != nil && s.tidb.Ready(ctx) == nil - postHogReady := s.posthog != nil && s.posthog.Ready(ctx) == nil status := http.StatusOK - if !tidbReady || !postHogReady { + if !tidbReady { status = http.StatusServiceUnavailable } writeJSON(writer, status, map[string]any{ - "ok": tidbReady && postHogReady, - "tidb_configured": tidbReady, - "posthog_configured": postHogReady, + "ok": tidbReady, + "tidb_configured": tidbReady, }) } diff --git a/internal/telemetrybackend/server_test.go b/internal/telemetrybackend/server_test.go index d5ee231..b73f9ed 100644 --- a/internal/telemetrybackend/server_test.go +++ b/internal/telemetrybackend/server_test.go @@ -16,7 +16,7 @@ import ( func TestServerAcceptsValidBatchWith202BeforeFlush(t *testing.T) { cfg := testConfig() batcher := NewBatcher(cfg, nil, discardLogger(), nil) - server := NewServer(cfg, batcher, readinessStub{}, readinessStub{}, discardLogger(), nil) + server := NewServer(cfg, batcher, readinessStub{}, discardLogger(), nil) response := performBatchRequest(server.Handler(), validRequestBody()) if response.Code != http.StatusAccepted { @@ -42,7 +42,7 @@ func TestServerAcceptsCurrentAndLegacyCLIUserAgents(t *testing.T) { t.Run(userAgent, func(t *testing.T) { cfg := testConfig() batcher := NewBatcher(cfg, nil, discardLogger(), nil) - server := NewServer(cfg, batcher, readinessStub{}, readinessStub{}, discardLogger(), nil) + server := NewServer(cfg, batcher, readinessStub{}, discardLogger(), nil) request := newBatchRequest(validRequestBody()) request.Header.Set("User-Agent", userAgent) response := httptest.NewRecorder() @@ -109,7 +109,7 @@ func TestServerRejectsInvalidRequestsWithGenericErrors(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { batcher := NewBatcher(cfg, nil, discardLogger(), nil) - server := NewServer(cfg, batcher, readinessStub{}, readinessStub{}, discardLogger(), nil) + server := NewServer(cfg, batcher, readinessStub{}, discardLogger(), nil) request := httptest.NewRequest(test.method, "/v1/telemetry/batch", bytes.NewReader(test.body)) request.Header.Set("Content-Type", "application/json") request.Header.Set("User-Agent", "ti/0.2.0") @@ -133,7 +133,7 @@ func TestServerDoesNotLogRequestBodiesOrInstallationIDs(t *testing.T) { var logs bytes.Buffer logger := slog.New(slog.NewJSONHandler(&logs, nil)) batcher := NewBatcher(cfg, nil, logger, nil) - server := NewServer(cfg, batcher, readinessStub{}, readinessStub{}, logger, nil) + server := NewServer(cfg, batcher, readinessStub{}, logger, nil) body := bytes.Replace( validRequestBody(), @@ -157,7 +157,7 @@ func TestServerReturns503WhenBufferIsFull(t *testing.T) { cfg.BufferMaxEvents = 1 cfg.FlushMaxEvents = 1 batcher := NewBatcher(cfg, nil, discardLogger(), nil) - server := NewServer(cfg, batcher, readinessStub{}, readinessStub{}, discardLogger(), nil) + server := NewServer(cfg, batcher, readinessStub{}, discardLogger(), nil) if response := performBatchRequest(server.Handler(), validRequestBody()); response.Code != http.StatusAccepted { t.Fatalf("first status = %d", response.Code) } @@ -172,7 +172,7 @@ func TestServerRateLimitsByTrustedForwardedIP(t *testing.T) { cfg.RateLimitBurst = 1 cfg.TrustedProxyCIDRs, _ = parseTrustedProxyCIDRs("192.0.2.0/24") batcher := NewBatcher(cfg, nil, discardLogger(), nil) - server := NewServer(cfg, batcher, readinessStub{}, readinessStub{}, discardLogger(), nil) + server := NewServer(cfg, batcher, readinessStub{}, discardLogger(), nil) request := newBatchRequest(validRequestBody()) request.RemoteAddr = "192.0.2.10:1234" @@ -196,7 +196,7 @@ func TestServerRateLimitsByTrustedForwardedIP(t *testing.T) { func TestServerHealthAndReadiness(t *testing.T) { cfg := testConfig() batcher := NewBatcher(cfg, nil, discardLogger(), nil) - server := NewServer(cfg, batcher, readinessStub{}, readinessStub{}, discardLogger(), nil) + server := NewServer(cfg, batcher, readinessStub{}, discardLogger(), nil) health := httptest.NewRecorder() server.Handler().ServeHTTP(health, httptest.NewRequest(http.MethodGet, "/healthz", nil)) @@ -208,12 +208,14 @@ func TestServerHealthAndReadiness(t *testing.T) { if ready.Code != http.StatusOK { t.Fatalf("ready status = %d", ready.Code) } + if strings.Contains(ready.Body.String(), "posthog") { + t.Fatalf("readiness response exposes removed PostHog dependency: %s", ready.Body.String()) + } unreadyServer := NewServer( cfg, batcher, readinessStub{err: errors.New("db unavailable")}, - readinessStub{}, discardLogger(), nil, ) @@ -228,7 +230,7 @@ func TestServerExportsAggregateMetricsWithoutEventValues(t *testing.T) { cfg := testConfig() metrics := &Metrics{} batcher := NewBatcher(cfg, nil, discardLogger(), metrics) - server := NewServer(cfg, batcher, readinessStub{}, readinessStub{}, discardLogger(), metrics) + server := NewServer(cfg, batcher, readinessStub{}, discardLogger(), metrics) if response := performBatchRequest(server.Handler(), validRequestBody()); response.Code != http.StatusAccepted { t.Fatalf("ingest status = %d", response.Code) } @@ -242,6 +244,9 @@ func TestServerExportsAggregateMetricsWithoutEventValues(t *testing.T) { !strings.Contains(response.Body.String(), "telemetry_buffer_events 1") { t.Fatalf("metrics body = %s", response.Body.String()) } + if strings.Contains(response.Body.String(), "posthog") { + t.Fatalf("metrics expose removed PostHog sink: %s", response.Body.String()) + } if strings.Contains(response.Body.String(), "ti_01j0a0n8m9f4q2x6cn0b9q3k3z") { t.Fatalf("metrics exposed installation ID: %s", response.Body.String()) } @@ -295,7 +300,7 @@ func TestReadyUsesSinkTimeout(t *testing.T) { cfg := testConfig() cfg.SinkTimeout = 10 * time.Millisecond batcher := NewBatcher(cfg, nil, discardLogger(), nil) - server := NewServer(cfg, batcher, blockingReadiness{}, readinessStub{}, discardLogger(), nil) + server := NewServer(cfg, batcher, blockingReadiness{}, discardLogger(), nil) response := httptest.NewRecorder() server.Handler().ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/readyz", nil)) if response.Code != http.StatusServiceUnavailable { @@ -305,7 +310,7 @@ func TestReadyUsesSinkTimeout(t *testing.T) { func TestServerConvertsUnexpectedPanicTo500(t *testing.T) { cfg := testConfig() - server := NewServer(cfg, nil, readinessStub{}, readinessStub{}, discardLogger(), nil) + server := NewServer(cfg, nil, readinessStub{}, discardLogger(), nil) response := performBatchRequest(server.Handler(), validRequestBody()) if response.Code != http.StatusInternalServerError { t.Fatalf("status = %d, want 500", response.Code) diff --git a/internal/telemetrybackend/test_helpers_test.go b/internal/telemetrybackend/test_helpers_test.go index 6e15220..7e0d018 100644 --- a/internal/telemetrybackend/test_helpers_test.go +++ b/internal/telemetrybackend/test_helpers_test.go @@ -26,8 +26,6 @@ func testConfig() Config { RateLimitPerMinute: 60, RateLimitBurst: 120, TiDBDSN: "user:password@tcp(localhost:4000)/telemetry?tls=true", - PostHogAPIHost: "https://us.i.posthog.com", - PostHogProjectToken: "phc_test", } } diff --git a/ref/drive9 b/ref/drive9 index 99aceec..ee40c65 160000 --- a/ref/drive9 +++ b/ref/drive9 @@ -1 +1 @@ -Subproject commit 99aceec47c949c1b6f74233109cbdf8e10fb9d56 +Subproject commit ee40c65c6c966aff222f3aeff48d68127c4dc54e diff --git a/ref/fs b/ref/fs index f6669db..2fad6f6 160000 --- a/ref/fs +++ b/ref/fs @@ -1 +1 @@ -Subproject commit f6669db3d41e2e5295f6b68d4d26aaecd9b780da +Subproject commit 2fad6f6d815e1283bc09ce41b1de96d81e3546c6