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) <noreply@anthropic.com>
This commit is contained in:
johba 2026-03-23 13:04:24 +00:00
parent 8d67e61c17
commit ca2bc03567
17 changed files with 195 additions and 6 deletions

View file

@ -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
}

16
containers/init-umami-db.sh Executable file
View file

@ -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

View file

@ -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

View file

@ -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/

View file

@ -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:

View file

@ -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=<your-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

View file

@ -5,7 +5,6 @@
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>KrAIken</title>
<script defer src="https://cloud.umami.is/script.js" data-website-id="6e2869f2-02a5-4346-9b3e-3c27e24ea5d3"></script>
</head>
<body>
<div id="app"></div>

View file

@ -17,6 +17,7 @@
"prepare": "husky"
},
"dependencies": {
"@harb/analytics": "*",
"@harb/web3": "*",
"@harb/ui-shared": "*",
"@tanstack/vue-query": "^5.92.9",

View file

@ -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 });

View file

@ -10,7 +10,7 @@
$KRK has a price floor backed by real ETH. The protocol adapts automatically. You just hold.
</div>
<div class="header-cta">
<KButton @click="router.push('/app/get-krk')">Get $KRK</KButton>
<KButton @click="navigateCta('/app/get-krk', 'hero_get_krk')">Get $KRK</KButton>
</div>
<div class="blur-effect"></div>
</div>
@ -38,7 +38,7 @@
</div>
</div>
<div class="centered-cta">
<KButton @click="router.push('/app/get-krk')">Get $KRK</KButton>
<KButton @click="navigateCta('/app/get-krk', 'how_it_works_get_krk')">Get $KRK</KButton>
</div>
</section>
<section class="protocol-health-section">
@ -51,7 +51,7 @@
<p>
Watch the protocol in real time. Supply dynamics, ETH reserves, position history all live, no wallet needed.
</p>
<KButton @click="router.push('/app')">View Protocol</KButton>
<KButton @click="navigateCta('/app', 'view_protocol')">View Protocol</KButton>
</template>
</LeftRightComponent>
</section>
@ -68,8 +68,8 @@
<li>Hold. The protocol does the rest.</li>
</ol>
<div class="button-group">
<KButton @click="router.push('/app/get-krk')">Get $KRK</KButton>
<KButton @click="router.push('/docs/how-it-works')">How It Works </KButton>
<KButton @click="navigateCta('/app/get-krk', 'getting_started_get_krk')">Get $KRK</KButton>
<KButton @click="navigateCta('/docs/how-it-works', 'how_it_works_docs')">How It Works </KButton>
</div>
</template>
</LeftRightComponent>
@ -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');
};

View file

@ -0,0 +1,8 @@
{
"name": "@harb/analytics",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts"
}

View file

@ -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<string, string | number | boolean>) => 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<string, string | number | boolean>,
): 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');
}

View file

@ -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": "*"
},

View file

@ -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<br /> Staker Dashboard', '$KRK');
waiting.value = false;

View file

@ -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,

View file

@ -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<GetBalanceReturnType>({
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();

View file

@ -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);