From 28fe32bdb5d040608e0e1c7ec58047ae210b4c0a Mon Sep 17 00:00:00 2001 From: Ben Limmer Date: Mon, 25 May 2026 10:57:50 -0600 Subject: [PATCH] chore: add report analytics --- README.md | 14 +-- bun.lock | 73 ++++++++++++++- package.json | 1 + scripts/build-report-web.ts | 13 ++- src/Analytics.test.ts | 3 +- src/Analytics.ts | 75 +--------------- src/PostHogAnalytics.ts | 75 ++++++++++++++++ src/cli.ts | 23 ++++- src/context.ts | 29 +++++- src/index.ts | 5 +- src/report/html.test.ts | 39 ++++++-- src/report/html.ts | 31 ++++--- src/report/reportAnalyticsConfig.ts | 13 +++ src/report/testFactories.ts | 8 ++ src/report/web/App.browser.test.tsx | 80 +++++++++++++++++ src/report/web/acts/CallToAction.tsx | 4 + src/report/web/acts/Verdict.tsx | 3 + src/report/web/analytics/AnalyticsContext.tsx | 14 +++ src/report/web/analytics/index.test.ts | 89 +++++++++++++++++++ src/report/web/analytics/index.ts | 31 +++++++ .../web/analytics/postHogAnalytics.test.ts | 81 +++++++++++++++++ src/report/web/analytics/postHogAnalytics.ts | 67 ++++++++++++++ .../web/analytics/stableUrlRewriter.test.ts | 80 +++++++++++++++++ src/report/web/analytics/stableUrlRewriter.ts | 33 +++++++ .../web/data/readEmbeddedAnalytics.test.ts | 38 ++++++++ src/report/web/data/readEmbeddedAnalytics.ts | 28 ++++++ src/report/web/index.html | 3 + src/report/web/index.tsx | 19 +++- src/report/web/primitives/AssumptionInput.tsx | 13 ++- src/testHelpers/FakeAnalytics.ts | 9 +- src/testHelpers/createFakeContext.ts | 2 + 31 files changed, 876 insertions(+), 120 deletions(-) create mode 100644 src/PostHogAnalytics.ts create mode 100644 src/report/reportAnalyticsConfig.ts create mode 100644 src/report/web/analytics/AnalyticsContext.tsx create mode 100644 src/report/web/analytics/index.test.ts create mode 100644 src/report/web/analytics/index.ts create mode 100644 src/report/web/analytics/postHogAnalytics.test.ts create mode 100644 src/report/web/analytics/postHogAnalytics.ts create mode 100644 src/report/web/analytics/stableUrlRewriter.test.ts create mode 100644 src/report/web/analytics/stableUrlRewriter.ts create mode 100644 src/report/web/data/readEmbeddedAnalytics.test.ts create mode 100644 src/report/web/data/readEmbeddedAnalytics.ts diff --git a/README.md b/README.md index e5310c8..00c0d71 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # patchwave-analysis -A diagnostic CLI that measures Dependabot toil and CVE exposure across a GitHub org. Runs in your environment and writes a self-contained HTML report plus a raw-data zip. No data leaves your network unless you choose to share the generated artifacts. +A diagnostic CLI that measures Dependabot toil and CVE exposure across a GitHub org. It writes a self-contained HTML report plus a raw-data zip. ## What it tells you @@ -86,7 +86,7 @@ data/contributors.json — active human committers per repo data/warnings.json — per-collector warnings suppressed during the crawl ``` -Nothing in the report or bundle leaves your machine unless you choose to share it. At the end of a run, you can keep everything local, share only the HTML report, or share the HTML report plus the raw-data zip. The archive does not include tokens, secrets, or repository file contents. +The report and bundle are not uploaded unless you choose to share them. The archive does not include tokens, secrets, or repository file contents. ## What it does not do @@ -96,15 +96,9 @@ Nothing in the report or bundle leaves your machine unless you choose to share i ## Telemetry & privacy -The CLI reports anonymous usage analytics and crash diagnostics so we can improve it. Both share a random UUID stored at `$XDG_CONFIG_HOME/contextbridge/anonymous_id` (or `~/.config/contextbridge/anonymous_id`) and used across contextbridge tools. **Org names, repo names, tokens, report contents, and your machine's hostname are never sent.** +Official binaries send product analytics and crash diagnostics so we can improve the tool. Builds from source do not include telemetry keys. -**Product analytics (PostHog)** — coarse usage events only: when a run starts, finishes, or fails, and the choices you make at the share and open prompts, along with aggregate counts (repos, PRs, warnings), durations, and error kinds. - -**Error reporting (Sentry)** — only when the CLI hits an _unexpected_ crash. It sends the error and stack trace, the release version, the anonymous id, and generic environment context (OS, CPU architecture, runtime version, locale/timezone). To keep the guarantee above, breadcrumbs and request data are dropped and the hostname is stripped before anything is sent. Expected problems — bad arguments, missing GitHub auth, GitHub API errors, or failed writes — are shown to you in the prompt and recorded as anonymous analytics, but are **not** sent to Sentry. - -Telemetry keys are baked only into official released binaries, so builds you make yourself from source send nothing. - -Both are disabled together by setting any of: +Disable telemetry with any of: - `DO_NOT_TRACK=1` - `CONTEXTBRIDGE_TELEMETRY_DISABLED=1` diff --git a/bun.lock b/bun.lock index 4d41698..613e695 100644 --- a/bun.lock +++ b/bun.lock @@ -17,6 +17,7 @@ "open": "^11.0.0", "pino": "^10.3.1", "pino-pretty": "^13.1.3", + "posthog-js": "^1.370.0", "posthog-node": "^5.35.1", "react": "^19", "react-dom": "^19", @@ -245,10 +246,12 @@ "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], - "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.214.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA=="], + "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.208.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg=="], "@opentelemetry/core": ["@opentelemetry/core@2.7.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw=="], + "@opentelemetry/exporter-logs-otlp-http": ["@opentelemetry/exporter-logs-otlp-http@0.208.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.208.0", "@opentelemetry/core": "2.2.0", "@opentelemetry/otlp-exporter-base": "0.208.0", "@opentelemetry/otlp-transformer": "0.208.0", "@opentelemetry/sdk-logs": "0.208.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-jOv40Bs9jy9bZVLo/i8FwUiuCvbjWDI+ZW13wimJm4LjnlwJxGgB+N/VWOZUTpM+ah/awXeQqKdNlpLf2EjvYg=="], + "@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.214.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.214.0", "import-in-the-middle": "^3.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-MHqEX5Dk59cqVah5LiARMACku7jXSVk9iVDWOea4x3cr7VfdByeDCURK6o1lntT1JS/Tsovw01UJrBhN3/uC5w=="], "@opentelemetry/instrumentation-amqplib": ["@opentelemetry/instrumentation-amqplib@0.61.0", "", { "dependencies": { "@opentelemetry/core": "^2.0.0", "@opentelemetry/instrumentation": "^0.214.0", "@opentelemetry/semantic-conventions": "^1.33.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-mCKoyTGfRNisge4br0NpOFSy2Z1NnEW8hbCJdUDdJFHrPqVzc4IIBPA/vX0U+LUcQqrQvJX+HMIU0dbDRe0i0Q=="], @@ -287,8 +290,16 @@ "@opentelemetry/instrumentation-tedious": ["@opentelemetry/instrumentation-tedious@0.33.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.214.0", "@opentelemetry/semantic-conventions": "^1.33.0", "@types/tedious": "^4.0.14" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-Q6WQwAD01MMTub31GlejoiFACYNw26J426wyjvU7by7fDIr2nZXNW4vhTGs7i7F0TnXBO3xN688g1tdUgYwJ5w=="], + "@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.208.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/otlp-transformer": "0.208.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-gMd39gIfVb2OgxldxUtOwGJYSH8P1kVFFlJLuut32L6KgUC4gl1dMhn+YC2mGn0bDOiQYSk/uHOdSjuKp58vvA=="], + + "@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.208.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.208.0", "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0", "@opentelemetry/sdk-logs": "0.208.0", "@opentelemetry/sdk-metrics": "2.2.0", "@opentelemetry/sdk-trace-base": "2.2.0", "protobufjs": "^7.3.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-DCFPY8C6lAQHUNkzcNT9R+qYExvsk6C5Bto2pbNxgicpcSWbe2WHShLxkOxIdNcBiYPdVHv/e7vH7K6TI+C+fQ=="], + "@opentelemetry/resources": ["@opentelemetry/resources@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ=="], + "@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.208.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.208.0", "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-QlAyL1jRpOeaqx7/leG1vJMp84g0xKP6gJmfELBpnI4O/9xPX+Hu5m1POk9Kl+veNkyth5t19hRlN6tNY1sjbA=="], + + "@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw=="], + "@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.7.1", "", { "dependencies": { "@opentelemetry/core": "2.7.1", "@opentelemetry/resources": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw=="], "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], @@ -421,6 +432,26 @@ "@prisma/instrumentation": ["@prisma/instrumentation@7.6.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.207.0" }, "peerDependencies": { "@opentelemetry/api": "^1.8" } }, "sha512-ZPW2gRiwpPzEfgeZgaekhqXrbW+Y2RJKHVqUmlhZhKzRNCcvR6DykzylDrynpArKKRQtLxoZy36fK7U0p3pdgQ=="], + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], + + "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], + + "@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="], + + "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.1", "", {}, "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="], + + "@protobufjs/fetch": ["@protobufjs/fetch@1.1.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1" } }, "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw=="], + + "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], + + "@protobufjs/inquire": ["@protobufjs/inquire@1.1.2", "", {}, "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw=="], + + "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], + + "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], + + "@protobufjs/utf8": ["@protobufjs/utf8@1.1.1", "", {}, "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg=="], + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="], "@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="], @@ -585,6 +616,8 @@ "@types/tedious": ["@types/tedious@4.0.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw=="], + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.4", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.4", "@typescript-eslint/type-utils": "8.59.4", "@typescript-eslint/utils": "8.59.4", "@typescript-eslint/visitor-keys": "8.59.4", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.4", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.4", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.4", "@typescript-eslint/types": "8.59.4", "@typescript-eslint/typescript-estree": "8.59.4", "@typescript-eslint/visitor-keys": "8.59.4", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ=="], @@ -735,6 +768,8 @@ "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "core-js": ["core-js@3.49.0", "", {}, "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="], @@ -763,6 +798,8 @@ "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], + "dompurify": ["dompurify@3.4.5", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA=="], + "electron-to-chromium": ["electron-to-chromium@1.5.361", "", {}, "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA=="], "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], @@ -957,6 +994,8 @@ "log-update": ["log-update@6.1.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", "slice-ansi": "^7.1.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w=="], + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="], "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], @@ -1057,10 +1096,14 @@ "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], + "posthog-js": ["posthog-js@1.376.0", "", { "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/api-logs": "^0.208.0", "@opentelemetry/exporter-logs-otlp-http": "^0.208.0", "@opentelemetry/resources": "^2.2.0", "@opentelemetry/sdk-logs": "^0.208.0", "@posthog/core": "1.29.9", "@posthog/types": "1.376.0", "core-js": "^3.38.1", "dompurify": "^3.3.2", "fflate": "^0.4.8", "preact": "^10.28.2", "query-selector-shadow-dom": "^1.0.1", "web-vitals": "^5.1.0" } }, "sha512-YGfQ6gSmqmEh287PHjXRDJ9zML3Su1UIt1+xjRy7Yk6yW43Sc7sFK3CpCkLchCGhIA4x6VaqK+LaqB+7+MCo7A=="], + "posthog-node": ["posthog-node@5.35.1", "", { "dependencies": { "@posthog/core": "1.29.9" }, "peerDependencies": { "rxjs": "^7.0.0" }, "optionalPeers": ["rxjs"] }, "sha512-F9S3pEIYfGEVjLYIFHKaqfTIhn5IpS02Dkp7C/f1rqr4Z67Iqbt4jbKO8raWsT0veEI3rUp+DKuXLW1hN07FQA=="], "powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="], + "preact": ["preact@10.29.2", "", {}, "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ=="], + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], "prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="], @@ -1069,10 +1112,14 @@ "process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="], + "protobufjs": ["protobufjs@7.6.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-4K0myLaWL5EteuSAro91EGFgcfVgxb64Jx+7oDAY6GOkXD4M69yuSEljNcInGVCA5sOPxmZ/EqDLj2x0Q0+Ygg=="], + "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "query-selector-shadow-dom": ["query-selector-shadow-dom@1.0.1", "", {}, "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw=="], + "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="], "react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="], @@ -1211,6 +1258,8 @@ "vitest": ["vitest@4.1.7", "", { "dependencies": { "@vitest/expect": "4.1.7", "@vitest/mocker": "4.1.7", "@vitest/pretty-format": "4.1.7", "@vitest/runner": "4.1.7", "@vitest/snapshot": "4.1.7", "@vitest/spy": "4.1.7", "@vitest/utils": "4.1.7", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.7", "@vitest/browser-preview": "4.1.7", "@vitest/browser-webdriverio": "4.1.7", "@vitest/coverage-istanbul": "4.1.7", "@vitest/coverage-v8": "4.1.7", "@vitest/ui": "4.1.7", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA=="], + "web-vitals": ["web-vitals@5.2.0", "", {}, "sha512-i2z98bEmaCqSDiHEDu+gHl/dmR4Q+TxFmG3/13KkMO+o8UxQzCqWaDRCiLgEa41nlO4VpXSI0ASa1xWmO9sBlA=="], + "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], @@ -1245,8 +1294,28 @@ "@fastify/otel/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.212.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.212.0", "import-in-the-middle": "^2.0.6", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IyXmpNnifNouMOe0I/gX7ENfv2ZCNdYTF0FpCsoBcpbIHzk81Ww9rQTYTnvghszCg7qGrIhNvWC8dhEifgX9Jg=="], + "@opentelemetry/exporter-logs-otlp-http/@opentelemetry/core": ["@opentelemetry/core@2.2.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw=="], + + "@opentelemetry/instrumentation/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.214.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA=="], + "@opentelemetry/instrumentation-http/@opentelemetry/core": ["@opentelemetry/core@2.6.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g=="], + "@opentelemetry/otlp-exporter-base/@opentelemetry/core": ["@opentelemetry/core@2.2.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw=="], + + "@opentelemetry/otlp-transformer/@opentelemetry/core": ["@opentelemetry/core@2.2.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw=="], + + "@opentelemetry/otlp-transformer/@opentelemetry/resources": ["@opentelemetry/resources@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A=="], + + "@opentelemetry/otlp-transformer/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw=="], + + "@opentelemetry/sdk-logs/@opentelemetry/core": ["@opentelemetry/core@2.2.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw=="], + + "@opentelemetry/sdk-logs/@opentelemetry/resources": ["@opentelemetry/resources@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A=="], + + "@opentelemetry/sdk-metrics/@opentelemetry/core": ["@opentelemetry/core@2.2.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw=="], + + "@opentelemetry/sdk-metrics/@opentelemetry/resources": ["@opentelemetry/resources@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A=="], + "@prisma/instrumentation/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.207.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.207.0", "import-in-the-middle": "^2.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-y6eeli9+TLKnznrR8AZlQMSJT7wILpXH+6EYq5Vf/4Ao+huI7EedxQHwRgVUOMLFbe7VFDvHJrX9/f4lcwnJsA=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], @@ -1283,6 +1352,8 @@ "path-scurry/lru-cache": ["lru-cache@11.5.0", "", {}, "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA=="], + "posthog-js/fflate": ["fflate@0.4.8", "", {}, "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA=="], + "redent/strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], "rollup/@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], diff --git a/package.json b/package.json index b1584de..dcf6d07 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "open": "^11.0.0", "pino": "^10.3.1", "pino-pretty": "^13.1.3", + "posthog-js": "^1.370.0", "posthog-node": "^5.35.1", "react": "^19", "react-dom": "^19", diff --git a/scripts/build-report-web.ts b/scripts/build-report-web.ts index 9699814..c2a8319 100644 --- a/scripts/build-report-web.ts +++ b/scripts/build-report-web.ts @@ -8,6 +8,11 @@ const result = await Bun.build({ minify: true, define: { 'process.env.NODE_ENV': '"production"', + // Same build-time telemetry injection as the CLI binary (see .goreleaser.yaml). The CLI + // compile gets these via `--define`; the web bundle is a separate build, so it reads the same + // env vars here. Absent (local/dev) they fall back to '', which disables report analytics. + __PW_POSTHOG_KEY__: JSON.stringify(process.env['__PW_POSTHOG_KEY__'] ?? ''), + __PW_POSTHOG_HOST__: JSON.stringify(process.env['__PW_POSTHOG_HOST__'] ?? ''), }, plugins: [tailwindPlugin], }); @@ -19,9 +24,11 @@ if (!result.success) { const out = result.outputs[0]?.path ?? 'dist/report-web/index.html'; const html = await Bun.file('./dist/report-web/index.html').text(); -if (!html.includes('__PATCHWAVE_DATA__')) { - console.error('dist/report-web/index.html is missing __PATCHWAVE_DATA__'); - process.exit(1); +for (const placeholder of ['__PATCHWAVE_DATA__', '__PATCHWAVE_ANALYTICS__']) { + if (!html.includes(placeholder)) { + console.error(`dist/report-web/index.html is missing ${placeholder}`); + process.exit(1); + } } if (html.includes('jsxDEV')) { console.error('dist/report-web/index.html contains React development markers'); diff --git a/src/Analytics.test.ts b/src/Analytics.test.ts index 14701e5..3dbb56d 100644 --- a/src/Analytics.test.ts +++ b/src/Analytics.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'bun:test'; -import { AnalyticsImpl, NoopAnalytics, type PostHogClient } from './Analytics.ts'; +import { NoopAnalytics } from './Analytics.ts'; +import { AnalyticsImpl, type PostHogClient } from './PostHogAnalytics.ts'; interface RecordedIdentify { readonly distinctId: string | undefined; diff --git a/src/Analytics.ts b/src/Analytics.ts index eab7ebb..7aaf3f2 100644 --- a/src/Analytics.ts +++ b/src/Analytics.ts @@ -1,7 +1,3 @@ -import { ResultAsync, fromThrowable } from 'neverthrow'; -import { PostHog } from 'posthog-node'; -import { POSTHOG_HOST, POSTHOG_KEY } from './buildInfo.ts'; - export interface Analytics { identify(distinctId: string, properties?: Record): void; capture(event: string, properties?: Record): void; @@ -10,68 +6,6 @@ export interface Analytics { shutdown(): Promise; } -export type PostHogClient = Pick; - -export interface AnalyticsImplOptions { - readonly distinctId: string; - readonly version: string; - readonly client?: PostHogClient; -} - -export class AnalyticsImpl implements Analytics { - readonly #distinctId: string; - readonly #client: PostHogClient; - readonly #superProperties: Record; - readonly #safeIdentify: (input: Parameters[0]) => void; - readonly #safeCapture: (input: Parameters[0]) => void; - - constructor(options: AnalyticsImplOptions) { - this.#distinctId = options.distinctId; - this.#client = options.client ?? createDefaultClient(); - this.#superProperties = { - pw_surface: 'cli', - pw_version: options.version, - }; - // Wrap PostHog calls in neverthrow so a telemetry failure (network, bad - // payload) is explicit and can never escape into the CLI's control flow. - const safeIdentify = fromThrowable(this.#client.identify.bind(this.#client)); - const safeCapture = fromThrowable(this.#client.capture.bind(this.#client)); - this.#safeIdentify = (input) => { - void safeIdentify(input); - }; - this.#safeCapture = (input) => { - void safeCapture(input); - }; - } - - identify(distinctId: string, properties?: Record): void { - this.#safeIdentify({ - distinctId, - properties: { ...this.#superProperties, ...properties }, - }); - } - - capture(event: string, properties?: Record): void { - this.#safeCapture({ - distinctId: this.#distinctId, - event, - properties: { ...this.#superProperties, ...properties }, - }); - } - - register(properties: Record): void { - Object.assign(this.#superProperties, properties); - } - - async flush(): Promise { - await ResultAsync.fromPromise(this.#client.flush(), (err: unknown) => err).unwrapOr(undefined); - } - - async shutdown(): Promise { - await ResultAsync.fromPromise(this.#client.shutdown(), (err: unknown) => err).unwrapOr(undefined); - } -} - export class NoopAnalytics implements Analytics { identify(_distinctId: string, _properties?: Record): void {} capture(_event: string, _properties?: Record): void {} @@ -80,11 +14,6 @@ export class NoopAnalytics implements Analytics { async shutdown(): Promise {} } -function createDefaultClient(): PostHog { - return new PostHog(POSTHOG_KEY, { - host: POSTHOG_HOST, - // Short flush window for short-lived CLI processes; flushAt=1 sends eagerly. - flushAt: 1, - flushInterval: 1000, - }); +export function createNoopAnalytics(): Analytics { + return new NoopAnalytics(); } diff --git a/src/PostHogAnalytics.ts b/src/PostHogAnalytics.ts new file mode 100644 index 0000000..f171950 --- /dev/null +++ b/src/PostHogAnalytics.ts @@ -0,0 +1,75 @@ +import { ResultAsync, fromThrowable } from 'neverthrow'; +import { PostHog } from 'posthog-node'; +import type { Analytics } from './Analytics.ts'; +import { POSTHOG_HOST, POSTHOG_KEY } from './buildInfo.ts'; + +export type PostHogClient = Pick; + +export interface AnalyticsImplOptions { + readonly distinctId: string; + readonly version: string; + readonly client?: PostHogClient; +} + +export class AnalyticsImpl implements Analytics { + readonly #distinctId: string; + readonly #client: PostHogClient; + readonly #superProperties: Record; + readonly #safeIdentify: (input: Parameters[0]) => void; + readonly #safeCapture: (input: Parameters[0]) => void; + + constructor(options: AnalyticsImplOptions) { + this.#distinctId = options.distinctId; + this.#client = options.client ?? createDefaultClient(); + this.#superProperties = { + pw_surface: 'cli', + pw_version: options.version, + }; + // Wrap PostHog calls in neverthrow so a telemetry failure (network, bad + // payload) is explicit and can never escape into the CLI's control flow. + const safeIdentify = fromThrowable(this.#client.identify.bind(this.#client)); + const safeCapture = fromThrowable(this.#client.capture.bind(this.#client)); + this.#safeIdentify = (input) => { + void safeIdentify(input); + }; + this.#safeCapture = (input) => { + void safeCapture(input); + }; + } + + identify(distinctId: string, properties?: Record): void { + this.#safeIdentify({ + distinctId, + properties: { ...this.#superProperties, ...properties }, + }); + } + + capture(event: string, properties?: Record): void { + this.#safeCapture({ + distinctId: this.#distinctId, + event, + properties: { ...this.#superProperties, ...properties }, + }); + } + + register(properties: Record): void { + Object.assign(this.#superProperties, properties); + } + + async flush(): Promise { + await ResultAsync.fromPromise(this.#client.flush(), (err: unknown) => err).unwrapOr(undefined); + } + + async shutdown(): Promise { + await ResultAsync.fromPromise(this.#client.shutdown(), (err: unknown) => err).unwrapOr(undefined); + } +} + +function createDefaultClient(): PostHog { + return new PostHog(POSTHOG_KEY, { + host: POSTHOG_HOST, + // Short flush window for short-lived CLI processes; flushAt=1 sends eagerly. + flushAt: 1, + flushInterval: 1000, + }); +} diff --git a/src/cli.ts b/src/cli.ts index 5050239..cc56f42 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -17,6 +17,7 @@ import { formatPromptError } from './prompt/Prompter.ts'; import { type ReportBundle, aggregate } from './report/aggregate.ts'; import { type BundleMeta, buildBundleFiles, zipBundleFiles } from './report/bundle.ts'; import { type RenderError, renderHtml } from './report/html.ts'; +import type { ReportAnalyticsConfig } from './report/reportAnalyticsConfig.ts'; import { type Instant, Temporal } from './time.ts'; import type { BranchProtectionSlice, @@ -83,6 +84,11 @@ export async function main(ctx: Context, argv: readonly string[]): Promise { +function renderReport( + ctx: Context, + opts: ResolvedOptions, + analytics: ReportAnalyticsConfig, +): ResultAsync { ctx.logger.info( { target: opts.target, windowDays: opts.windowDays }, `scanning ${opts.target} (${opts.windowDays}-day window)`, @@ -250,7 +267,7 @@ function renderReport(ctx: Context, opts: ResolvedOptions): ResultAsync ({ report, collected: data, diff --git a/src/context.ts b/src/context.ts index 7146baf..2340bf4 100644 --- a/src/context.ts +++ b/src/context.ts @@ -1,11 +1,10 @@ -import type { Analytics } from './Analytics.ts'; -import { NoopAnalytics } from './Analytics.ts'; +import { type Analytics, NoopAnalytics } from './Analytics.ts'; import type { Io } from './BaseIo.ts'; import type { BrowserOpener } from './BrowserOpener.ts'; import { BrowserOpenerImpl } from './BrowserOpener.ts'; import type { Clock } from './Clock.ts'; import { ClockImpl } from './Clock.ts'; -import { type Environment, getEnvironment } from './environment.ts'; +import { type Environment, getEnvironment, isTelemetryDisabled } from './environment.ts'; import type { FileSystem } from './FileSystem.ts'; import { FileSystemImpl } from './FileSystem.ts'; import type { GithubClient } from './github/GithubClient.ts'; @@ -29,6 +28,10 @@ export interface Context { readonly uploader: Uploader; readonly browserOpener: BrowserOpener; readonly appVersion: string; + // Anonymous telemetry id of this machine ('' when telemetry is disabled). Embedded into the + // report so report-view events can be tied back to the run that produced them. + readonly distinctId: string; + readonly telemetryDisabled: boolean; } export interface CreateContextOptions { @@ -44,6 +47,8 @@ export interface CreateContextOptions { readonly prompter?: Prompter; readonly uploader?: Uploader; readonly browserOpener?: BrowserOpener; + readonly distinctId?: string; + readonly telemetryDisabled?: boolean; } export function createContext(options: CreateContextOptions): Context { @@ -58,8 +63,24 @@ export function createContext(options: CreateContextOptions): Context { prompter = new PrompterImpl(), uploader = new UploaderImpl(), browserOpener = new BrowserOpenerImpl(), + distinctId = '', } = options; const logger = options.logger ?? createLogger({ level: env.LOG_LEVEL, destination: io.stderr }); const githubClient = options.githubClient ?? new GithubClientImpl({ token, logger }); - return { io, logger, env, clock, fs, githubClient, analytics, prompter, uploader, browserOpener, appVersion }; + const telemetryDisabled = options.telemetryDisabled ?? isTelemetryDisabled(env); + return { + io, + logger, + env, + clock, + fs, + githubClient, + analytics, + prompter, + uploader, + browserOpener, + appVersion, + distinctId, + telemetryDisabled, + }; } diff --git a/src/index.ts b/src/index.ts index 6443023..5e43fd6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ #!/usr/bin/env bun import pkg from '../package.json' with { type: 'json' }; -import { type Analytics, AnalyticsImpl, NoopAnalytics } from './Analytics.ts'; +import { type Analytics, NoopAnalytics } from './Analytics.ts'; import { getOrCreateAnonymousId } from './anonymousId.ts'; import { POSTHOG_KEY, SENTRY_DSN } from './buildInfo.ts'; import { main, parseCli } from './cli.ts'; @@ -13,6 +13,7 @@ import { formatInteractiveTokenError, interactiveResolveToken } from './interact import { enforceTty } from './interactive/ttyGate.ts'; import { IoImpl } from './IoImpl.ts'; import { createLogger } from './logger.ts'; +import { AnalyticsImpl } from './PostHogAnalytics.ts'; import { PrompterImpl } from './prompt/Prompter.ts'; import { NoopTelemetry, type Telemetry, createSentryTelemetry } from './Telemetry.ts'; import { UploaderImpl } from './upload/Uploader.ts'; @@ -81,6 +82,8 @@ const ctx = createContext({ analytics, prompter, uploader: new UploaderImpl(), + distinctId, + telemetryDisabled, }); const result = await main(ctx, argv); diff --git a/src/report/html.test.ts b/src/report/html.test.ts index f455423..df2baa1 100644 --- a/src/report/html.test.ts +++ b/src/report/html.test.ts @@ -1,9 +1,11 @@ import { describe, expect, test } from 'bun:test'; import { renderHtmlFrom, toEmbeddedShape } from './html.ts'; -import { reportBundle, reportMeta } from './testFactories.ts'; +import { reportAnalyticsConfig, reportBundle, reportMeta } from './testFactories.ts'; const STUB_TEMPLATE = - ''; + ''; + +const analytics = reportAnalyticsConfig.build(); const LINE_SEP = String.fromCharCode(0x2028); const PARA_SEP = String.fromCharCode(0x2029); @@ -32,7 +34,7 @@ describe('toEmbeddedShape', () => { describe('renderHtmlFrom', () => { test('substitutes the placeholder and embeds parseable JSON', () => { const bundle = reportBundle.build(); - const result = renderHtmlFrom(STUB_TEMPLATE, bundle); + const result = renderHtmlFrom(STUB_TEMPLATE, bundle, analytics); expect(result.isOk()).toBe(true); const rendered = result.unwrapOr(''); @@ -46,22 +48,41 @@ describe('renderHtmlFrom', () => { expect(parsed.meta.org).toBe(bundle.meta.org); }); - test('returns a missing-placeholder err when the placeholder is absent', () => { - const bundle = reportBundle.build(); - const result = renderHtmlFrom('no placeholder', bundle); + test('substitutes the analytics placeholder with the embedded config', () => { + const result = renderHtmlFrom(STUB_TEMPLATE, reportBundle.build(), analytics); + expect(result.isOk()).toBe(true); + const rendered = result.unwrapOr(''); + + expect(rendered).not.toContain('__PATCHWAVE_ANALYTICS__'); + const match = /'; + const result = renderHtmlFrom(dataOnly, reportBundle.build(), analytics); + expect(result.isErr()).toBe(true); + if (result.isErr()) expect(result.error.message).toContain('__PATCHWAVE_ANALYTICS__'); + }); + test('escapes sequences inside the embedded JSON', () => { const bundle = reportBundle.build({ meta: reportMeta.build({ org: 'malicious' }), }); - const result = renderHtmlFrom(STUB_TEMPLATE, bundle); + const result = renderHtmlFrom(STUB_TEMPLATE, bundle, analytics); expect(result.isOk()).toBe(true); const rendered = result.unwrapOr(''); const scriptCloses = rendered.match(/<\/script>/gi) ?? []; - expect(scriptCloses).toHaveLength(1); + expect(scriptCloses).toHaveLength(2); expect(rendered).toContain('<\\/script>'); }); @@ -69,7 +90,7 @@ describe('renderHtmlFrom', () => { const bundle = reportBundle.build({ meta: reportMeta.build({ org: `a