diff --git a/deploy/.env.vps.example b/deploy/.env.vps.example new file mode 100644 index 0000000..be9589c --- /dev/null +++ b/deploy/.env.vps.example @@ -0,0 +1,32 @@ +# Copy to ./.env on the VPS and fill in. Never commit the real .env. + +# Domain for Caddy automatic HTTPS. Point its DNS A record to this VPS IP first. +DOMAIN=arbpulse.example.com + +NODE_ENV=production +PORT=8080 + +# Engine tuning (mirrors render.yaml production defaults) +STALE_MS=3000 +FLICKER_CONFIRM_MS=150 +MIN_NET_PROFIT_PCT=0.0005 +MAX_TRADE_BTC=0.25 +LATENCY_MS=120 +LATENCY_SLIPPAGE_BPS=2 +CIRCUIT_BREAKER_LOSSES=5 +CIRCUIT_BREAKER_COOLDOWN_MS=15000 +INITIAL_USDT=50000 +INITIAL_BTC=0.5 +DEMO_MODE=false +RECORD_FEED=false + +# Observability. Error monitoring is on when SENTRY_DSN is set. +# Leave SENTRY_TRACING=false for 24/7 (keeps spans off the free-tier quota). +SENTRY_DSN= +SENTRY_TRACING=false +LOG_LEVEL=info + +# Upstash (optional): rate limiting on /api/* + short-TTL snapshot cache. +# Leave empty to disable both features. +UPSTASH_REDIS_REST_URL= +UPSTASH_REDIS_REST_TOKEN= diff --git a/deploy/Caddyfile b/deploy/Caddyfile new file mode 100644 index 0000000..0298676 --- /dev/null +++ b/deploy/Caddyfile @@ -0,0 +1,9 @@ +# Automatic HTTPS via Let's Encrypt. DOMAIN comes from ./.env (docker compose +# substitutes ${DOMAIN}). Point the domain's DNS A record to this VPS first. +{$DOMAIN} { + # flush_interval -1 disables response buffering so SSE (/api/stream) streams + # in real time. Do NOT gzip event-stream responses (would buffer them). + reverse_proxy app:8080 { + flush_interval -1 + } +} diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..b94f7ac --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,82 @@ +# Deploy en VPS (Hetzner) — Arb Pulse + +Despliegue con Docker Compose: el contenedor `app` (Node + tsx) detrás de +`caddy`, que emite HTTPS automáticamente (Let's Encrypt). Pensado para correr +24/7 desde la rama `main`. + +## Prerrequisitos + +1. VPS Ubuntu/Debian (recomendado 2 vCPU / 2 GB RAM — p. ej. Hetzner CX22). +2. Un dominio con **registro DNS A apuntando a la IP del VPS** (necesario para el + HTTPS de Caddy). +3. Puertos abiertos: + ```bash + sudo ufw allow 22 && sudo ufw allow 80 && sudo ufw allow 443 && sudo ufw --force enable + ``` + +## Deploy (para el agente del VPS) + +Todo lo hace el script idempotente `deploy.sh` (instala Docker si falta, clona o +actualiza el repo, y levanta los contenedores). Es seguro re-ejecutarlo. + +```bash +# 1) Traer el script y ejecutarlo (primera vez: crea .env y se detiene) +curl -fsSL https://raw.githubusercontent.com/mauricioabh/arbpulse/main/deploy/deploy.sh -o /tmp/arbpulse-deploy.sh +bash /tmp/arbpulse-deploy.sh + +# 2) Editar el .env con el dominio y secretos +nano /opt/arbpulse/deploy/.env # DOMAIN=... ; SENTRY_DSN=... (opcional) ; UPSTASH_* (opcional) + +# 3) Volver a ejecutar para construir y arrancar +bash /tmp/arbpulse-deploy.sh +``` + +Alternativa (si ya está clonado el repo en `/opt/arbpulse`): + +```bash +cd /opt/arbpulse/deploy +cp .env.vps.example .env && nano .env # solo la primera vez +docker compose up -d --build +``` + +## Verificar + +```bash +docker compose -f /opt/arbpulse/deploy/docker-compose.yml ps +curl -s http://127.0.0.1:8080/api/health # local +curl -s https://TU_DOMINIO/api/health # público (tras propagar DNS) +``` + +Dashboard: `https://TU_DOMINIO` → badge **LIVE** y los 4 exchanges en la matriz. + +## Variables de entorno + +Ver `.env.vps.example`. Claves: + +- `DOMAIN` — dominio para Caddy/HTTPS (obligatorio). +- `SENTRY_DSN` — opcional; activa error monitoring. +- `SENTRY_TRACING` — dejar en `false` (default) para no consumir la cuota + free-tier de spans en operación 24/7. +- `UPSTASH_REDIS_REST_URL` / `UPSTASH_REDIS_REST_TOKEN` — opcionales (rate limit + + cache). Vacío = desactivado. + +> Nunca comitees el `.env` real. Solo `.env.vps.example` vive en el repo. + +## Operación + +```bash +cd /opt/arbpulse/deploy +docker compose logs -f app # logs de la app +docker compose restart app # reiniciar +docker compose down # detener +bash /tmp/arbpulse-deploy.sh # actualizar (git pull main + rebuild) +``` + +## Notas + +- Caddy usa `flush_interval -1` para que el SSE (`/api/stream`) fluya en tiempo + real; no comprimir `text/event-stream`. +- El puerto 8080 se publica solo en `127.0.0.1` (no expuesto a Internet); el + tráfico público entra por Caddy (80/443). +- Región: Hetzner no tiene Asia; para menor latencia a Binance/Bybit/OKX evalúa + un VPS en Singapur. Para Kraken (EU) Falkenstein/Helsinki va bien. diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100755 index 0000000..c5bfc78 --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Idempotent VPS deploy for Arb Pulse (Ubuntu/Debian). +# Safe to re-run: installs Docker if missing, clones or fast-forwards the repo, +# then (re)builds and restarts the containers. On first run it creates .env from +# the example and stops so you can fill in DOMAIN + secrets. +set -euo pipefail + +REPO_URL="${REPO_URL:-https://github.com/mauricioabh/arbpulse.git}" +APP_DIR="${APP_DIR:-/opt/arbpulse}" +BRANCH="${BRANCH:-main}" + +echo "==> Arb Pulse VPS deploy (branch: $BRANCH, dir: $APP_DIR)" + +# 1) Docker + compose plugin +if ! command -v docker >/dev/null 2>&1; then + echo "==> Installing Docker Engine..." + curl -fsSL https://get.docker.com | sh +fi +docker --version +if ! docker compose version >/dev/null 2>&1; then + echo "ERROR: 'docker compose' plugin not available. Install docker-compose-plugin." >&2 + exit 1 +fi + +# 2) Repo (clone or fast-forward) +if [ ! -d "$APP_DIR/.git" ]; then + echo "==> Cloning $REPO_URL into $APP_DIR" + sudo mkdir -p "$APP_DIR" + sudo chown "$(id -un)":"$(id -gn)" "$APP_DIR" + git clone --branch "$BRANCH" "$REPO_URL" "$APP_DIR" +else + echo "==> Updating existing repo in $APP_DIR" + git -C "$APP_DIR" fetch origin "$BRANCH" + git -C "$APP_DIR" checkout "$BRANCH" + git -C "$APP_DIR" pull --ff-only origin "$BRANCH" +fi + +cd "$APP_DIR/deploy" + +# 3) Environment file +if [ ! -f .env ]; then + cp .env.vps.example .env + echo + echo "==> Created $APP_DIR/deploy/.env from the example." + echo " Edit it now (set DOMAIN, and optionally SENTRY_DSN / UPSTASH_*)," + echo " then run this script again to build and start." + exit 0 +fi + +# 4) Build + run +echo "==> Building and starting containers..." +docker compose up -d --build + +echo "==> Waiting for health..." +sleep 8 +docker compose ps +echo +if curl -fsS http://127.0.0.1:8080/api/health >/dev/null 2>&1; then + echo "==> App healthy on 127.0.0.1:8080." +else + echo "!! Health check not passing yet. Inspect: docker compose logs -f app" +fi +echo "==> If your domain's A record points here, Caddy will issue HTTPS automatically." +echo "==> Verify: curl -s https://\$DOMAIN/api/health" diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..bd50f48 --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,43 @@ +# Arb Pulse — VPS deployment (app + Caddy reverse proxy with automatic HTTPS). +# Usage: from this directory, `docker compose up -d --build`. +# Requires a ./.env file (copy from .env.vps.example and fill DOMAIN + secrets). + +services: + app: + build: + context: .. + dockerfile: Dockerfile + image: arbpulse:latest + container_name: arbpulse + restart: unless-stopped + env_file: + - ./.env + # Bind to loopback only; public traffic goes through Caddy on 80/443. + ports: + - "127.0.0.1:8080:8080" + healthcheck: + test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/api/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s + + caddy: + image: caddy:2-alpine + container_name: arbpulse-caddy + restart: unless-stopped + depends_on: + - app + ports: + - "80:80" + - "443:443" + environment: + - DOMAIN=${DOMAIN} + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + +volumes: + caddy_data: + caddy_config: diff --git a/openspec/changes/gate-sentry-spans/.openspec.yaml b/openspec/changes/archive/2026-07-16-gate-sentry-spans/.openspec.yaml similarity index 100% rename from openspec/changes/gate-sentry-spans/.openspec.yaml rename to openspec/changes/archive/2026-07-16-gate-sentry-spans/.openspec.yaml diff --git a/openspec/changes/gate-sentry-spans/design.md b/openspec/changes/archive/2026-07-16-gate-sentry-spans/design.md similarity index 100% rename from openspec/changes/gate-sentry-spans/design.md rename to openspec/changes/archive/2026-07-16-gate-sentry-spans/design.md diff --git a/openspec/changes/gate-sentry-spans/proposal.md b/openspec/changes/archive/2026-07-16-gate-sentry-spans/proposal.md similarity index 100% rename from openspec/changes/gate-sentry-spans/proposal.md rename to openspec/changes/archive/2026-07-16-gate-sentry-spans/proposal.md diff --git a/openspec/changes/gate-sentry-spans/specs/observability/spec.md b/openspec/changes/archive/2026-07-16-gate-sentry-spans/specs/observability/spec.md similarity index 100% rename from openspec/changes/gate-sentry-spans/specs/observability/spec.md rename to openspec/changes/archive/2026-07-16-gate-sentry-spans/specs/observability/spec.md diff --git a/openspec/changes/gate-sentry-spans/tasks.md b/openspec/changes/archive/2026-07-16-gate-sentry-spans/tasks.md similarity index 100% rename from openspec/changes/gate-sentry-spans/tasks.md rename to openspec/changes/archive/2026-07-16-gate-sentry-spans/tasks.md diff --git a/openspec/specs/observability/spec.md b/openspec/specs/observability/spec.md new file mode 100644 index 0000000..ea21cdd --- /dev/null +++ b/openspec/specs/observability/spec.md @@ -0,0 +1,47 @@ +# observability Specification + +## Purpose +TBD - created by archiving change gate-sentry-spans. Update Purpose after archive. +## Requirements +### Requirement: Error monitoring is always active + +The system SHALL report runtime errors to Sentry whenever `SENTRY_DSN` is configured, independent of any tracing configuration. This includes REST, WebSocket feed, and SSE errors captured via `Sentry.captureException`, plus process-level `unhandledRejection` and `uncaughtException` handlers. + +#### Scenario: Error captured with DSN set + +- **WHEN** `SENTRY_DSN` is set and a handled error occurs on the WebSocket, REST, or SSE path +- **THEN** the error is sent to Sentry as an error event + +#### Scenario: No DSN configured + +- **WHEN** `SENTRY_DSN` is empty or unset +- **THEN** Sentry is not initialized and no events are sent, and the application continues running normally + +### Requirement: Tracing spans are gated by an environment flag + +The system SHALL only create and export OpenTelemetry spans when the `SENTRY_TRACING` environment variable is enabled AND `SENTRY_DSN` is set. The flag SHALL default to disabled so that continuous 24/7 operation emits zero spans by default. + +#### Scenario: Tracing disabled by default + +- **WHEN** `SENTRY_TRACING` is unset or falsy (default) +- **THEN** the tracer is a no-op, no spans are created on the hot path, and no spans are exported to Sentry + +#### Scenario: Tracing enabled with DSN + +- **WHEN** `SENTRY_TRACING` is truthy and `SENTRY_DSN` is set +- **THEN** hot-path spans (`ws.message`, `orderbook.process`, `arbitrage.evaluate`, `sse.broadcast`) are created and exported to Sentry, and `tracesSampleRate` is applied + +#### Scenario: Tracing enabled without DSN + +- **WHEN** `SENTRY_TRACING` is truthy but `SENTRY_DSN` is empty or unset +- **THEN** the tracer is a no-op and no spans are exported, since export requires a DSN + +### Requirement: Error capture is preserved when tracing is disabled + +The system SHALL continue to report exceptions thrown inside hot-path work wrappers to Sentry even when tracing is disabled, so disabling spans never reduces error visibility. + +#### Scenario: Exception in hot-path wrapper with tracing off + +- **WHEN** `SENTRY_TRACING` is disabled and code wrapped by the hot-path span helper throws +- **THEN** the exception is still captured and reported to Sentry (as an error), and re-thrown to preserve existing control flow +