From ca2bc035678b9abcf5a15e114398d627129a5357 Mon Sep 17 00:00:00 2001 From: johba Date: Mon, 23 Mar 2026 13:04:24 +0000 Subject: [PATCH] fix: feat: basic analytics funnel tracking for launch readiness (#1101) Add self-hosted Umami analytics to replace the third-party cloud.umami.is tracker. Creates @harb/analytics package with typed event helpers and instruments the conversion funnel: CTA clicks (landing), wallet connect, swap initiated, and stake created (web-app). - Add Umami Docker service sharing existing postgres (separate DB) - Add Caddy /analytics route to proxy Umami dashboard - Configure via VITE_UMAMI_URL and VITE_UMAMI_WEBSITE_ID env vars - Document setup and funnel events in docs/ENVIRONMENT.md Co-Authored-By: Claude Opus 4.6 (1M context) --- containers/Caddyfile | 4 ++ containers/init-umami-db.sh | 16 +++++++ containers/landing-entrypoint.sh | 3 ++ containers/webapp-entrypoint.sh | 4 ++ docker-compose.yml | 29 +++++++++++ docs/ENVIRONMENT.md | 35 ++++++++++++++ landing/index.html | 1 - landing/package.json | 1 + landing/src/main.ts | 3 ++ landing/src/views/HomeView.vue | 16 +++++-- packages/analytics/package.json | 8 ++++ packages/analytics/src/index.ts | 69 +++++++++++++++++++++++++++ web-app/package.json | 1 + web-app/src/composables/useStake.ts | 2 + web-app/src/composables/useSwapKrk.ts | 3 ++ web-app/src/composables/useWallet.ts | 2 + web-app/src/main.ts | 4 ++ 17 files changed, 195 insertions(+), 6 deletions(-) create mode 100755 containers/init-umami-db.sh create mode 100644 packages/analytics/package.json create mode 100644 packages/analytics/src/index.ts diff --git a/containers/Caddyfile b/containers/Caddyfile index e6b88dc..eca7688 100644 --- a/containers/Caddyfile +++ b/containers/Caddyfile @@ -18,5 +18,9 @@ uri strip_prefix /api/txn reverse_proxy txn-bot:43069 } + route /analytics* { + uri strip_prefix /analytics + reverse_proxy umami:3000 + } reverse_proxy landing:5174 } diff --git a/containers/init-umami-db.sh b/containers/init-umami-db.sh new file mode 100755 index 0000000..e7dff02 --- /dev/null +++ b/containers/init-umami-db.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# Creates the umami database and user if they don't already exist. +# Mounted as a postgres init script via docker-compose volumes. +set -e + +psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL + DO \$\$ + BEGIN + IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'umami') THEN + CREATE ROLE umami WITH LOGIN PASSWORD 'umami_local'; + END IF; + END + \$\$; + SELECT 'CREATE DATABASE umami OWNER umami' + WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'umami')\gexec +EOSQL diff --git a/containers/landing-entrypoint.sh b/containers/landing-entrypoint.sh index efacad8..79f7d2f 100755 --- a/containers/landing-entrypoint.sh +++ b/containers/landing-entrypoint.sh @@ -26,4 +26,7 @@ if [[ -f "$CONTRACTS_ENV" ]]; then echo "[landing-entrypoint] Contract addresses loaded: KRK=${KRAIKEN:-unset} STAKE=${STAKE:-unset}" fi +export VITE_UMAMI_URL="${VITE_UMAMI_URL:-}" +export VITE_UMAMI_WEBSITE_ID="${VITE_UMAMI_WEBSITE_ID:-}" + exec npm run dev -- --host 0.0.0.0 --port 5174 diff --git a/containers/webapp-entrypoint.sh b/containers/webapp-entrypoint.sh index 5c612a1..67c277a 100755 --- a/containers/webapp-entrypoint.sh +++ b/containers/webapp-entrypoint.sh @@ -34,6 +34,8 @@ if [[ "${CI:-}" == "true" ]]; then export VITE_SWAP_ROUTER=${VITE_SWAP_ROUTER:-0x94cC0AaC535CCDB3C01d6787D6413C739ae12bc4} export VITE_PONDER_BASE_SEPOLIA_LOCAL_FORK=${VITE_PONDER_BASE_SEPOLIA_LOCAL_FORK:-/api/graphql} export VITE_TXNBOT_BASE_SEPOLIA_LOCAL_FORK=${VITE_TXNBOT_BASE_SEPOLIA_LOCAL_FORK:-/api/txn} + export VITE_UMAMI_URL="${VITE_UMAMI_URL:-}" + export VITE_UMAMI_WEBSITE_ID="${VITE_UMAMI_WEBSITE_ID:-}" echo "[webapp-ci] Environment configured:" echo " VITE_KRAIKEN_ADDRESS: ${VITE_KRAIKEN_ADDRESS}" @@ -81,5 +83,7 @@ export VITE_SWAP_ROUTER=$SWAP_ROUTER export VITE_PONDER_BASE_SEPOLIA_LOCAL_FORK=${VITE_PONDER_BASE_SEPOLIA_LOCAL_FORK:-/api/graphql} export VITE_TXNBOT_BASE_SEPOLIA_LOCAL_FORK=${VITE_TXNBOT_BASE_SEPOLIA_LOCAL_FORK:-/api/txn} export CHOKIDAR_USEPOLLING=${CHOKIDAR_USEPOLLING:-1} +export VITE_UMAMI_URL="${VITE_UMAMI_URL:-}" +export VITE_UMAMI_WEBSITE_ID="${VITE_UMAMI_WEBSITE_ID:-}" exec npm run dev -- --host 0.0.0.0 --port 5173 --base /app/ diff --git a/docker-compose.yml b/docker-compose.yml index b73a366..7fcee69 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -52,6 +52,7 @@ services: - POSTGRES_DB=ponder_local volumes: - postgres-data:/var/lib/postgresql/data + - ./containers/init-umami-db.sh:/docker-entrypoint-initdb.d/init-umami-db.sh:ro,z expose: - "5432" restart: unless-stopped @@ -133,6 +134,8 @@ services: - CHOKIDAR_USEPOLLING=1 - GIT_BRANCH=${GIT_BRANCH:-} - VITE_ENABLE_LOCAL_SWAP=true + - VITE_UMAMI_URL=${VITE_UMAMI_URL:-} + - VITE_UMAMI_WEBSITE_ID=${VITE_UMAMI_WEBSITE_ID:-} expose: - "5173" ports: @@ -167,6 +170,8 @@ services: - CHOKIDAR_USEPOLLING=1 - GIT_BRANCH=${GIT_BRANCH:-} - VITE_APP_URL=http://localhost:5173/app + - VITE_UMAMI_URL=${VITE_UMAMI_URL:-} + - VITE_UMAMI_WEBSITE_ID=${VITE_UMAMI_WEBSITE_ID:-} expose: - "5174" restart: unless-stopped @@ -228,6 +233,30 @@ services: retries: 3 start_period: 2s + umami: + image: ghcr.io/umami-software/umami:postgresql-latest + environment: + - DATABASE_URL=postgresql://umami:umami_local@postgres:5432/umami + - APP_SECRET=${UMAMI_APP_SECRET:-harb-analytics-secret} + - DISABLE_TELEMETRY=1 + expose: + - "3000" + ports: + - "127.0.0.1:3000:3000" + restart: unless-stopped + networks: + - harb-network + depends_on: + postgres: + condition: service_healthy + logging: *default-logging + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:3000/api/heartbeat"] + interval: 5s + timeout: 3s + retries: 10 + start_period: 15s + otterscan: image: otterscan/otterscan:v2.6.0 environment: diff --git a/docs/ENVIRONMENT.md b/docs/ENVIRONMENT.md index 8b31b0e..d96b238 100644 --- a/docs/ENVIRONMENT.md +++ b/docs/ENVIRONMENT.md @@ -16,6 +16,7 @@ Docker Compose services (in startup order): | **webapp** | Staking app (Vue 3) | 5173 | HTTP response | | **txn-bot** | Automated `recenter()` and `payTax()` upkeep ([services/txnBot/](../services/txnBot/)) | 43069 | Process alive | | **caddy** | Reverse proxy / TLS | 80/443 | — | +| **umami** | Self-hosted analytics (Umami) | 3000 | HTTP /api/heartbeat | | **otterscan** | Block explorer | 5100 | — | ## txnBot Service @@ -119,6 +120,7 @@ docker inspect landing --format '{{.NetworkSettings.Networks.harb_harb-network.I - **Ponder GraphQL:** `http://localhost:42069/graphql` - **Anvil RPC:** `http://localhost:8545` - **txnBot status:** `http://localhost:43069/status` +- **Umami analytics:** `http://localhost:3000` (default login: `admin` / `umami`) ## Resource Notes @@ -140,6 +142,39 @@ For testing login: `lobsterDao`, `test123`, `lobster-x010syqe?412!` | `VITE_KRAIKEN_ADDRESS` | from `deployments-local.json` | via `contracts.env` + entrypoint | Override KRK token address. | | `VITE_STAKE_ADDRESS` | from `deployments-local.json` | via `contracts.env` + entrypoint | Override Stake contract address. | | `VITE_DEFAULT_CHAIN_ID` | auto-detected (31337 on localhost) | — | Force the default chain. | +| `VITE_UMAMI_URL` | unset | via env | Full URL to Umami `script.js` (e.g. `https://analytics.kraiken.org/script.js`). Omit to disable analytics. | +| `VITE_UMAMI_WEBSITE_ID` | unset | via env | Umami website ID (UUID). Required alongside `VITE_UMAMI_URL`. | + +## Analytics (Umami) + +Self-hosted [Umami](https://umami.is/) provides privacy-respecting funnel analytics with no third-party tracking. The `umami` Docker service shares the `postgres` instance (separate `umami` database created by `containers/init-umami-db.sh`). + +### Setup + +1. Start the stack — Umami comes up automatically. +2. Open `http://localhost:3000` and log in (default: `admin` / `umami`). Change the password on first login. +3. Add a website in Umami and copy the **Website ID** (UUID). +4. Set the env vars before starting landing/webapp: + ```bash + export VITE_UMAMI_URL=http://localhost:3000/script.js + export VITE_UMAMI_WEBSITE_ID= + ``` + For staging/production behind Caddy, use the `/analytics/script.js` path instead. + +### Tracked funnel events + +| Event | App | Trigger | +|-------|-----|---------| +| `cta_click` | landing | User clicks a CTA button (label in event data) | +| `wallet_connect` | web-app | Wallet connected for the first time | +| `swap_initiated` | web-app | User submits a buy or sell swap (direction in event data) | +| `stake_created` | web-app | Stake position successfully created | + +Page views are tracked automatically by the Umami script on every route change. + +### Production deployment + +On `harb-staging`, set `VITE_UMAMI_URL` and `VITE_UMAMI_WEBSITE_ID` in the environment and configure `UMAMI_APP_SECRET` to a strong random value. The Caddy route `/analytics*` proxies to the Umami container. ## Contract Addresses diff --git a/landing/index.html b/landing/index.html index 5e94391..ae4ffc8 100644 --- a/landing/index.html +++ b/landing/index.html @@ -5,7 +5,6 @@ KrAIken -
diff --git a/landing/package.json b/landing/package.json index e724ca8..c8266da 100644 --- a/landing/package.json +++ b/landing/package.json @@ -17,6 +17,7 @@ "prepare": "husky" }, "dependencies": { + "@harb/analytics": "*", "@harb/web3": "*", "@harb/ui-shared": "*", "@tanstack/vue-query": "^5.92.9", diff --git a/landing/src/main.ts b/landing/src/main.ts index cf3d3ae..16f9adb 100644 --- a/landing/src/main.ts +++ b/landing/src/main.ts @@ -5,9 +5,12 @@ import { WagmiPlugin } from '@wagmi/vue'; // that Wagmi uses internally for its reactive data hooks (useAccount, useConnect, etc.). import { VueQueryPlugin } from '@tanstack/vue-query'; import { createHarbConfig } from '@harb/web3'; +import { initAnalytics } from '@harb/analytics'; import App from './App.vue'; import router from './router'; +initAnalytics(import.meta.env.VITE_UMAMI_URL, import.meta.env.VITE_UMAMI_WEBSITE_ID); + const rpcUrl = import.meta.env.VITE_LOCAL_RPC_URL ?? '/api/rpc'; const config = createHarbConfig({ rpcUrl }); diff --git a/landing/src/views/HomeView.vue b/landing/src/views/HomeView.vue index 8b0be16..83b9108 100644 --- a/landing/src/views/HomeView.vue +++ b/landing/src/views/HomeView.vue @@ -10,7 +10,7 @@ $KRK has a price floor backed by real ETH. The protocol adapts automatically. You just hold.
- Get $KRK + Get $KRK
@@ -38,7 +38,7 @@
- Get $KRK + Get $KRK
@@ -51,7 +51,7 @@

Watch the protocol in real time. Supply dynamics, ETH reserves, position history — all live, no wallet needed.

- View Protocol + View Protocol
@@ -68,8 +68,8 @@
  • Hold. The protocol does the rest.
  • - Get $KRK - How It Works → + Get $KRK + How It Works →
    @@ -93,11 +93,17 @@ import LiveStats from '@/components/LiveStats.vue'; import SecurityInfo from '@/components/SecurityInfo.vue'; import WalletCard from '@/components/WalletCard.vue'; import { useMobile } from '@/composables/useMobile'; +import { trackCtaClick } from '@harb/analytics'; import { useRouter } from 'vue-router'; const isMobile = useMobile(); const router = useRouter(); +const navigateCta = (path: string, label: string) => { + trackCtaClick(label); + router.push(path); +}; + const openExternal = (url: string) => { window.open(url, '_blank', 'noopener'); }; diff --git a/packages/analytics/package.json b/packages/analytics/package.json new file mode 100644 index 0000000..a74009f --- /dev/null +++ b/packages/analytics/package.json @@ -0,0 +1,8 @@ +{ + "name": "@harb/analytics", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts" +} diff --git a/packages/analytics/src/index.ts b/packages/analytics/src/index.ts new file mode 100644 index 0000000..3be50a0 --- /dev/null +++ b/packages/analytics/src/index.ts @@ -0,0 +1,69 @@ +/** + * Lightweight analytics wrapper for self-hosted Umami. + * + * Call `initAnalytics()` once at app startup to inject the tracker + * script. All other helpers are safe to call at any time — if the + * tracker hasn't loaded (ad-blocker, missing config, dev without + * Umami running) every call is a silent no-op. + */ + +interface UmamiTracker { + track: (name: string, data?: Record) => void; +} + +declare global { + interface Window { + umami?: UmamiTracker; + } +} + +/** + * Inject the Umami tracker script. + * @param scriptUrl — full URL to `script.js`, e.g. `https://analytics.kraiken.org/script.js` + * @param websiteId — Umami website ID (UUID) + */ +export function initAnalytics(scriptUrl: string | undefined, websiteId: string | undefined): void { + if (!scriptUrl || !websiteId) return; + if (typeof document === 'undefined') return; + if (document.querySelector(`script[data-website-id="${websiteId}"]`)) return; + + const el = document.createElement('script'); + el.defer = true; + el.src = scriptUrl; + el.dataset.websiteId = websiteId; + document.head.appendChild(el); +} + +function getTracker(): UmamiTracker | undefined { + return typeof window !== 'undefined' ? window.umami : undefined; +} + +/** Fire a named event with optional properties. */ +export function trackEvent( + name: string, + data?: Record, +): void { + getTracker()?.track(name, data); +} + +/* ── Funnel events ─────────────────────────────────────────────── */ + +/** Landing page CTA clicked (e.g. "Get $KRK"). */ +export function trackCtaClick(label: string): void { + trackEvent('cta_click', { label }); +} + +/** Wallet successfully connected. */ +export function trackWalletConnect(): void { + trackEvent('wallet_connect'); +} + +/** Swap (buy/sell) initiated — user submitted the transaction. */ +export function trackSwapInitiated(direction: 'buy' | 'sell'): void { + trackEvent('swap_initiated', { direction }); +} + +/** Stake position created (snatch completed). */ +export function trackStakeCreated(): void { + trackEvent('stake_created'); +} diff --git a/web-app/package.json b/web-app/package.json index ddc06e7..56207f7 100644 --- a/web-app/package.json +++ b/web-app/package.json @@ -33,6 +33,7 @@ "vue-router": "^4.2.5", "vue-tippy": "^6.6.0", "vue-toastification": "^2.0.0-rc.5", + "@harb/analytics": "*", "@harb/web3": "*", "@harb/utils": "*" }, diff --git a/web-app/src/composables/useStake.ts b/web-app/src/composables/useStake.ts index 55225fe..3193b35 100644 --- a/web-app/src/composables/useStake.ts +++ b/web-app/src/composables/useStake.ts @@ -11,6 +11,7 @@ import { createPermitObject, getSignatureRSV } from '@/utils/blockchain'; import { weiToNumber, compactNumber } from 'kraiken-lib/format'; import { getTaxRateOptionByIndex } from '@/composables/useAdjustTaxRates'; import { useContractToast } from './useContractToast'; +import { trackStakeCreated } from '@harb/analytics'; const wallet = useWallet(); const contractToast = useContractToast(); const wagmiConfig: Config = config; @@ -129,6 +130,7 @@ export function useStake() { const eventArgs: PositionCreatedArgs = topics.args; const amount = compactNumber(weiToNumber(eventArgs.harbergDeposit, wallet.balance.decimals)); + trackStakeCreated(); contractToast.showSuccessToast(amount, 'Success!', 'You Staked', 'Check your positions on the
    Staker Dashboard', '$KRK'); waiting.value = false; diff --git a/web-app/src/composables/useSwapKrk.ts b/web-app/src/composables/useSwapKrk.ts index b46651e..7f19848 100644 --- a/web-app/src/composables/useSwapKrk.ts +++ b/web-app/src/composables/useSwapKrk.ts @@ -7,6 +7,7 @@ import { config as wagmiConfig } from '@/wagmi'; import { getChain, DEFAULT_CHAIN_ID } from '@/config'; import { useWallet } from '@/composables/useWallet'; import { getErrorMessage, ensureAddress } from '@harb/utils'; +import { trackSwapInitiated } from '@harb/analytics'; export const WETH_ABI = [ { @@ -217,6 +218,7 @@ export function useSwapKrk() { }); } + trackSwapInitiated('buy'); await writeContract(wagmiConfig, { abi: SWAP_ROUTER_ABI, address: router, @@ -327,6 +329,7 @@ export function useSwapKrk() { } sellPhase.value = 'selling'; + trackSwapInitiated('sell'); await writeContract(wagmiConfig, { abi: SWAP_ROUTER_ABI, address: router, diff --git a/web-app/src/composables/useWallet.ts b/web-app/src/composables/useWallet.ts index c501f48..609fc77 100644 --- a/web-app/src/composables/useWallet.ts +++ b/web-app/src/composables/useWallet.ts @@ -9,6 +9,7 @@ import { setStakeContract } from '@/contracts/stake'; import { chainsData, DEFAULT_CHAIN_ID } from '@/config'; import { createPublicClient, custom, formatUnits, type EIP1193Provider, type PublicClient, type Transport, type WalletClient } from 'viem'; import { getWalletPublicClient, setWalletPublicClient } from '@/services/walletRpc'; +import { trackWalletConnect } from '@harb/analytics'; const balance = ref({ value: 0n, @@ -118,6 +119,7 @@ export function useWallet() { }; } else if (account.value.address !== data.address || account.value.chainId !== data.chainId) { logger.info(`Account changed!:`, data.address); + if (!account.value.address && data.address) trackWalletConnect(); account.value = data; await syncWalletPublicClient(data); await loadBalance(); diff --git a/web-app/src/main.ts b/web-app/src/main.ts index 898b9df..acd9498 100644 --- a/web-app/src/main.ts +++ b/web-app/src/main.ts @@ -2,6 +2,7 @@ import { WagmiPlugin } from '@wagmi/vue'; import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query'; import { createApp } from 'vue'; import { config } from './wagmi'; +import { initAnalytics } from '@harb/analytics'; import ClickOutSide from '@/directives/ClickOutsideDirective'; import router from './router'; @@ -9,6 +10,9 @@ import App from './App.vue'; import './assets/styles/main.sass'; import Toast from 'vue-toastification'; import 'vue-toastification/dist/index.css'; + +initAnalytics(import.meta.env.VITE_UMAMI_URL, import.meta.env.VITE_UMAMI_WEBSITE_ID); + const queryClient = new QueryClient(); const app = createApp(App);