From 02a407580becf2a19b66a379b6c168401713c82e Mon Sep 17 00:00:00 2001 From: Gorka Date: Thu, 28 May 2026 14:14:05 -0300 Subject: [PATCH 1/2] feat(error-tracking): wire PostHog autocapture, manual captureException, source-map upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greenfield PostHog wiring (the repo had none). Errors-only scope โ€” no analytics-event capture, no opt-in UI (PostHog error tracking is treated as technical telemetry, not user tracking). - `src/lib/analytics.ts` (NEW): array.js shim loader gated on `IS_PRODUCTION && POSTHOG_KEY`; init passes `capture_exceptions: true`; exports a `captureException(error, properties?)` wrapper. - `src/lib/config.ts`: add `posthogKey` / `posthogHost` to the `DashboardConfig` interface + matching const exports. - `src/app.ts`: call `initAnalytics()` before bootstrap so the SDK loads in parallel with the WS connection. - `src/server.ts`: CSP extends `script-src` with `https://us-assets.i.posthog.com` and the production `connect-src` with `https://us.i.posthog.com`. Map blocker already in place. - `src/build.ts`: flip `sourcemap` always-on for production builds. - `deploy.yml` (testnet + mainnet): add `POSTHOG_KEY` env from `secrets.POSTHOG_PROJECT_TOKEN`, validate non-empty, inject `posthogKey` + `posthogHost` into the generated `config.js`. After build, run `posthog-cli sourcemap inject` + `... upload` and add `--exclude "*.map"` to the `aws s3 sync` so maps stay off Tigris. PostHog CLI env: `POSTHOG_CLI_API_KEY` from secrets, `POSTHOG_CLI_PROJECT_ID=345524`, `POSTHOG_CLI_HOST=https://us.posthog.com`. Init config uses `capture_exceptions: true` (posthog-js v1.376+ renamed from `autocapture_exceptions`). Verified locally with a production build + posthog-cli upload against the real personal API key. --- .github/workflows/deploy.yml | 30 ++++++++++++++++++++-- src/app.ts | 3 +++ src/build.ts | 2 +- src/lib/analytics.ts | 49 ++++++++++++++++++++++++++++++++++++ src/lib/config.ts | 5 ++++ src/server.ts | 4 +-- 6 files changed, 88 insertions(+), 5 deletions(-) create mode 100644 src/lib/analytics.ts diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b9bd9ec..3f5c872 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -12,6 +12,7 @@ jobs: NETWORK_DASHBOARD_PLATFORM_URL: "${{ secrets.NETWORK_DASHBOARD_PLATFORM_URL }}" STELLAR_NETWORK: "${{ secrets.STELLAR_NETWORK }}" RPC_URL: "${{ secrets.RPC_URL }}" + POSTHOG_KEY: "${{ secrets.POSTHOG_PROJECT_TOKEN }}" steps: - uses: actions/checkout@v4 @@ -22,7 +23,7 @@ jobs: - name: Validate testnet config inputs run: | set -euo pipefail - for var in COUNCIL_PLATFORM_URL NETWORK_DASHBOARD_PLATFORM_URL STELLAR_NETWORK RPC_URL; do + for var in COUNCIL_PLATFORM_URL NETWORK_DASHBOARD_PLATFORM_URL STELLAR_NETWORK RPC_URL POSTHOG_KEY; do if [ -z "${!var}" ]; then echo "::error::Required env var $var is empty" exit 1 @@ -49,6 +50,8 @@ jobs: rpcUrl: Deno.env.get('RPC_URL'), councilPlatformUrl: Deno.env.get('COUNCIL_PLATFORM_URL'), networkDashboardPlatformUrl: Deno.env.get('NETWORK_DASHBOARD_PLATFORM_URL'), + posthogKey: Deno.env.get('POSTHOG_KEY'), + posthogHost: 'https://us.i.posthog.com', }; Deno.writeTextFileSync('public/config.js', 'window.__DASHBOARD_CONFIG__ = ' + JSON.stringify(config, null, 2) + ';\n'); @@ -58,6 +61,15 @@ jobs: - name: Build production bundle run: deno task build -- --production + - name: Upload source maps to PostHog + env: + POSTHOG_CLI_API_KEY: "${{ secrets.POSTHOG_CLI_API_KEY }}" + POSTHOG_CLI_PROJECT_ID: "345524" + POSTHOG_CLI_HOST: "https://us.posthog.com" + run: | + npx --yes @posthog/cli sourcemap inject --directory public/ + npx --yes @posthog/cli sourcemap upload --directory public/ + - name: Deploy to Tigris (testnet) env: AWS_ACCESS_KEY_ID: "${{ secrets.TIGRIS_ACCESS_KEY_ID }}" @@ -68,6 +80,7 @@ jobs: --endpoint-url https://fly.storage.tigris.dev \ --acl public-read \ --delete \ + --exclude "*.map" \ --no-progress # config.js must NOT be edge-cached โ€” it carries the WS URL and other # deploy-time substitutions. Other assets keep the bucket default. @@ -86,6 +99,7 @@ jobs: NETWORK_DASHBOARD_PLATFORM_URL: "${{ secrets.MAINNET_NETWORK_DASHBOARD_PLATFORM_URL }}" STELLAR_NETWORK: "${{ secrets.MAINNET_STELLAR_NETWORK }}" RPC_URL: "${{ secrets.MAINNET_RPC_URL }}" + POSTHOG_KEY: "${{ secrets.POSTHOG_PROJECT_TOKEN }}" steps: - uses: actions/checkout@v4 @@ -96,7 +110,7 @@ jobs: - name: Validate mainnet config inputs run: | set -euo pipefail - for var in COUNCIL_PLATFORM_URL NETWORK_DASHBOARD_PLATFORM_URL STELLAR_NETWORK RPC_URL; do + for var in COUNCIL_PLATFORM_URL NETWORK_DASHBOARD_PLATFORM_URL STELLAR_NETWORK RPC_URL POSTHOG_KEY; do if [ -z "${!var}" ]; then echo "::error::Required env var $var is empty" exit 1 @@ -123,6 +137,8 @@ jobs: rpcUrl: Deno.env.get('RPC_URL'), councilPlatformUrl: Deno.env.get('COUNCIL_PLATFORM_URL'), networkDashboardPlatformUrl: Deno.env.get('NETWORK_DASHBOARD_PLATFORM_URL'), + posthogKey: Deno.env.get('POSTHOG_KEY'), + posthogHost: 'https://us.i.posthog.com', }; Deno.writeTextFileSync('public/config.js', 'window.__DASHBOARD_CONFIG__ = ' + JSON.stringify(config, null, 2) + ';\n'); @@ -132,6 +148,15 @@ jobs: - name: Build production bundle run: deno task build -- --production + - name: Upload source maps to PostHog + env: + POSTHOG_CLI_API_KEY: "${{ secrets.POSTHOG_CLI_API_KEY }}" + POSTHOG_CLI_PROJECT_ID: "345524" + POSTHOG_CLI_HOST: "https://us.posthog.com" + run: | + npx --yes @posthog/cli sourcemap inject --directory public/ + npx --yes @posthog/cli sourcemap upload --directory public/ + - name: Deploy to Tigris (mainnet) env: AWS_ACCESS_KEY_ID: "${{ secrets.MAINNET_TIGRIS_ACCESS_KEY_ID }}" @@ -142,6 +167,7 @@ jobs: --endpoint-url https://fly.storage.tigris.dev \ --acl public-read \ --delete \ + --exclude "*.map" \ --no-progress # config.js must NOT be edge-cached โ€” it carries the WS URL and other # deploy-time substitutions. Other assets keep the bucket default. diff --git a/src/app.ts b/src/app.ts index f9b6ceb..53cf92f 100644 --- a/src/app.ts +++ b/src/app.ts @@ -14,6 +14,7 @@ import { renderNav } from "@moonlight/ui/nav"; import { pageLayout } from "@moonlight/ui/layout"; import { NETWORK_DASHBOARD_PLATFORM_URL } from "./lib/config.ts"; +import { initAnalytics } from "./lib/analytics.ts"; import { connectNetworkPlatform } from "./lib/ws-client.ts"; import { CounterStrip } from "./views/counter-strip.ts"; import { ActivityFeed } from "./views/activity-feed.ts"; @@ -95,6 +96,8 @@ function renderShell(): { }; } +initAnalytics(); + function bootstrap() { const { counters, diff --git a/src/build.ts b/src/build.ts index 4c2f2f7..01d6835 100644 --- a/src/build.ts +++ b/src/build.ts @@ -67,7 +67,7 @@ await esbuild.build({ platform: "browser", target: "es2022", minify: isProduction, - sourcemap: !isProduction, + sourcemap: true, define: { "__APP_VERSION__": JSON.stringify(version), }, diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts new file mode 100644 index 0000000..2d83c07 --- /dev/null +++ b/src/lib/analytics.ts @@ -0,0 +1,49 @@ +import { IS_PRODUCTION, POSTHOG_HOST, POSTHOG_KEY } from "./config.ts"; + +/** + * PostHog error-tracking wrapper. + * NOOP in development; in production, autocaptures unhandled exceptions and + * exposes a manual `captureException` for caught error paths. + */ + +interface Analytics { + captureException(error: unknown, properties?: Record): void; +} + +const noop: Analytics = { + captureException() {}, +}; + +let analytics: Analytics = noop; + +export function initAnalytics(): void { + if (!IS_PRODUCTION || !POSTHOG_KEY) { + return; + } + + const script = document.createElement("script"); + script.src = "https://us-assets.i.posthog.com/static/array.js"; + script.onload = () => { + // deno-lint-ignore no-explicit-any + const posthog = (window as any).posthog; + if (posthog) { + posthog.init(POSTHOG_KEY, { + api_host: POSTHOG_HOST, + capture_exceptions: true, + person_profiles: "identified_only", + }); + analytics = { + captureException: (error, properties) => + posthog.captureException(error, properties), + }; + } + }; + document.head.appendChild(script); +} + +export function captureException( + error: unknown, + properties?: Record, +): void { + analytics.captureException(error, properties); +} diff --git a/src/lib/config.ts b/src/lib/config.ts index d46d012..496c7d2 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -11,6 +11,8 @@ interface DashboardConfig { environment?: string; stellarNetwork?: "testnet" | "mainnet" | "standalone"; networkDashboardPlatformUrl?: string; + posthogKey?: string; + posthogHost?: string; } declare global { @@ -43,3 +45,6 @@ export const STELLAR_NETWORK = c.stellarNetwork ?? "testnet"; */ export const NETWORK_DASHBOARD_PLATFORM_URL: string = (c.networkDashboardPlatformUrl ?? "").trim(); + +export const POSTHOG_KEY = c.posthogKey ?? ""; +export const POSTHOG_HOST = c.posthogHost ?? "https://us.i.posthog.com"; diff --git a/src/server.ts b/src/server.ts index a6307c1..80e56a9 100644 --- a/src/server.ts +++ b/src/server.ts @@ -23,7 +23,7 @@ function getCSP(): string { // blocked. Production allows-list the dashboard-api WS hosts the SPA // connects to; ws/wss schemes need explicit hosts even with 'self'. const connectSrc = isProd - ? "connect-src 'self' wss://dashboard-api.moonlightprotocol.io wss://dashboard-api-testnet.moonlightprotocol.io" + ? "connect-src 'self' wss://dashboard-api.moonlightprotocol.io wss://dashboard-api-testnet.moonlightprotocol.io https://us.i.posthog.com" : "connect-src *"; // ยง4 asset-breakdown bars and a couple of inline transitions render via // `style="..."`. The dashboard is public + anonymous, no auth boundary @@ -31,7 +31,7 @@ function getCSP(): string { const styleSrc = "style-src 'self' 'unsafe-inline'"; return [ "default-src 'self'", - "script-src 'self'", + "script-src 'self' https://us-assets.i.posthog.com", styleSrc, connectSrc, ].join("; "); From 034dbb54c4bf78534b620fbaf3bcb9f749ada2cd Mon Sep 17 00:00:00 2001 From: Gorka Date: Thu, 28 May 2026 14:22:18 -0300 Subject: [PATCH 2/2] chore: bump version to 0.2.15 --- deno.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deno.json b/deno.json index 986aa1d..cdb5adc 100644 --- a/deno.json +++ b/deno.json @@ -1,5 +1,5 @@ { - "version": "0.2.14", + "version": "0.2.15", "license": "MIT", "tasks": { "dev": "deno run --allow-all --watch src/server.ts",