diff --git a/.env.example b/.env.example index 394755a..ac3f910 100644 --- a/.env.example +++ b/.env.example @@ -25,5 +25,11 @@ RECORD_FEED=false # API docs — Scalar at GET /api-docs (OpenAPI generated from Zod; no env vars required) -# Sentry — https://sentry.io (project: arbpulse) +# Observability — https://sentry.io (project: arbpulse) SENTRY_DSN= +# Structured logs (pino); correlation IDs on REST, SSE, and WS hot path +LOG_LEVEL=info + +# Upstash Redis — rate limit on /api/* and short-TTL snapshot cache for GET /api/state +UPSTASH_REDIS_REST_URL= +UPSTASH_REDIS_REST_TOKEN= diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..0b80086 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,18 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 5 + + - package-ecosystem: npm + directory: "/web" + schedule: + interval: weekly + open-pull-requests-limit: 5 + + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..dfc9278 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,30 @@ +name: CodeQL Security Scan + +on: + push: + branches: [main, dev] + pull_request: + branches: [main, dev] + schedule: + - cron: "0 8 * * 1" + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + steps: + - uses: actions/checkout@v4 + + - uses: github/codeql-action/init@v3 + with: + languages: javascript-typescript + queries: security-extended + + - uses: github/codeql-action/autobuild@v3 + + - uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..652f47e --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,45 @@ +name: E2E Smoke + +on: + push: + branches: [main, dev] + pull_request: + branches: [main, dev] + +jobs: + smoke: + name: Playwright dashboard + SSE + runs-on: ubuntu-latest + env: + DEMO_MODE: "true" + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + cache-dependency-path: | + package-lock.json + web/package-lock.json + + - run: npm ci + - run: npm --prefix web ci + - run: npm run build + + - name: Start server + run: npm start & + + - name: Wait for API + run: | + for i in $(seq 1 45); do + curl -sf http://localhost:8080/api/health > /dev/null && echo "Ready after ${i}s" && exit 0 + sleep 1 + done + echo "::error::Server did not become ready in 45s" && exit 1 + + - run: npx playwright install --with-deps chromium + + - name: Run smoke tests + run: npm run test:e2e diff --git a/README.md b/README.md index a52c219..14d70a5 100644 --- a/README.md +++ b/README.md @@ -347,7 +347,10 @@ Eso no indica que el bot “no funcione”: indica que el filtro económico es e - **Pre-commit:** Husky runs lint-staged (`eslint --fix`, `prettier --write`) on staged `*.ts` / `*.tsx` in `src/` and `web/src/`. - **API contracts:** Zod schemas per REST endpoint → OpenAPI via `@asteasolutions/zod-to-openapi` → Scalar UI at `/api-docs`. Request bodies validated with Zod in route handlers. -- **Observability:** Sentry planned — set `SENTRY_DSN` in `.env` after creating the `arbpulse` project in Sentry. +- **Observability:** `@sentry/node` captures REST, WebSocket feed, and SSE errors; `pino` JSON logs with `correlationId` (from `x-request-id` or per book tick); OpenTelemetry spans (`ws.message` → `orderbook.process` → `arbitrage.evaluate` → `sse.broadcast`) export to Sentry via `@sentry/opentelemetry` when `SENTRY_DSN` is set. +- **Rate limiting & cache:** Upstash sliding-window limits per IP on read/write/SSE routes (`429` + `Retry-After`). Latest `StateSnapshot` cached in Redis (~1s TTL) for `GET /api/state` and refreshed on SSE broadcast. Set `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN`. +- **CI:** GitHub Actions quality pipeline (typecheck, unit tests, build); Playwright smoke on PRs — dashboard loads and SSE connects (`npm run test:e2e`, workflow `e2e.yml`, `DEMO_MODE=true` in CI). +- **Security scanning:** CodeQL (`.github/workflows/codeql.yml`); Dependabot for npm (root + `web/`) and GitHub Actions. --- diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts new file mode 100644 index 0000000..dad7834 --- /dev/null +++ b/e2e/smoke.spec.ts @@ -0,0 +1,19 @@ +import { test, expect } from "@playwright/test"; + +test("dashboard loads", async ({ page }) => { + await page.goto("/"); + await expect(page.getByRole("heading", { name: /ArbPulse/i })).toBeVisible(); +}); + +test("SSE stream connects and delivers engine state", async ({ page, request }) => { + const health = await request.get("/api/health"); + expect(health.ok()).toBeTruthy(); + + await page.goto("/"); + await expect(page.getByText("Connecting to engine…")).toBeHidden({ + timeout: 45_000, + }); + await expect(page.getByText("LIVE", { exact: true })).toBeVisible(); + await expect(page.getByText("RUNNING")).toBeVisible(); + await expect(page.getByText("Realized P&L")).toBeVisible(); +}); diff --git a/package-lock.json b/package-lock.json index 631a0fa..97bc9bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,14 +9,23 @@ "version": "1.0.0", "dependencies": { "@asteasolutions/zod-to-openapi": "^8.5.0", + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/sdk-trace-node": "^2.7.1", "@scalar/express-api-reference": "^0.8.48", + "@sentry/node": "^10.57.0", + "@sentry/opentelemetry": "^10.57.0", + "@upstash/ratelimit": "^2.0.6", + "@upstash/redis": "^1.35.3", "express": "^4.21.2", + "pino": "^10.3.1", + "pino-pretty": "^13.1.3", "tsx": "^4.19.2", "ws": "^8.18.0", "zod": "^4.4.3" }, "devDependencies": { "@eslint/js": "^9.18.0", + "@playwright/test": "^1.51.1", "@types/express": "^4.17.21", "@types/node": "^22.10.5", "@types/ws": "^8.5.13", @@ -734,6 +743,152 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.214.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.214.0.tgz", + "integrity": "sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/context-async-hooks": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.7.1.tgz", + "integrity": "sha512-OPFBYuXEn1E4ja3Y6eeA7O+ZnLBNcXTV5Cgsn1VaqBZ6hC5FnpZPLBNme1LJY8ZtF4aOujPKFoeWN4ik487KuQ==", + "license": "Apache-2.0", + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.1.tgz", + "integrity": "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/instrumentation": { + "version": "0.214.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.214.0.tgz", + "integrity": "sha512-MHqEX5Dk59cqVah5LiARMACku7jXSVk9iVDWOea4x3cr7VfdByeDCURK6o1lntT1JS/Tsovw01UJrBhN3/uC5w==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.214.0", + "import-in-the-middle": "^3.0.0", + "require-in-the-middle": "^8.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.1.tgz", + "integrity": "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.7.1.tgz", + "integrity": "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/resources": "2.7.1", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-node": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.7.1.tgz", + "integrity": "sha512-pCpQxU68lV+I9s9svqMyVu5iHdDDUnqUpSxqwyCU8A9ejEsSnMPCbearwsUO4yk08ZJzAIUCFuReMdVQvHrdvg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/context-async-hooks": "2.7.1", + "@opentelemetry/core": "2.7.1", + "@opentelemetry/sdk-trace-base": "2.7.1" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.41.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", + "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@playwright/test": { + "version": "1.51.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.51.1.tgz", + "integrity": "sha512-nM+kEaTSAoVlXmMPH10017vn3FSiFqr/bh4fKg9vmAdMfd9SDqRZNvPSiAHADc/itWak+qPvMPZQOPwCBW7k7Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.51.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@scalar/core": { "version": "0.3.45", "resolved": "https://registry.npmjs.org/@scalar/core/-/core-0.3.45.tgz", @@ -782,6 +937,108 @@ "node": ">=20" } }, + "node_modules/@sentry-internal/server-utils": { + "version": "10.57.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/server-utils/-/server-utils-10.57.0.tgz", + "integrity": "sha512-Qu8ETmX/ITzteG7Im46b9HOxKKzeaIeqNvftaIlFURu1RUQdHbtGerS7QOmXzwnhuqNGNeiCQYkduB798IfRqA==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.57.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/core": { + "version": "10.57.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.57.0.tgz", + "integrity": "sha512-kntItTA2kiT0YpL7encXaF6mkdZMB+y48lwj8w1wkfBpfJAC7sifdgrzLQZqmsqVNE3crg9VfufaAGA+78uFMg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/node": { + "version": "10.57.0", + "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.57.0.tgz", + "integrity": "sha512-7KEStrJ97wPf1fA5nU5ONeTTcIIlh7oT8OMffEVA1PXmlhFoXhcQZVzr4rM+zj9tfMWT01og5Ng/Grgh3dN+FA==", + "license": "MIT", + "dependencies": { + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/core": "^2.6.1", + "@opentelemetry/instrumentation": "^0.214.0", + "@opentelemetry/sdk-trace-base": "^2.6.1", + "@opentelemetry/semantic-conventions": "^1.40.0", + "@sentry-internal/server-utils": "10.57.0", + "@sentry/core": "10.57.0", + "@sentry/node-core": "10.57.0", + "@sentry/opentelemetry": "10.57.0", + "import-in-the-middle": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/node-core": { + "version": "10.57.0", + "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.57.0.tgz", + "integrity": "sha512-2v2IF6MfTiu7pimWEq2rYhZsmlwyNbs3bHUsrYFPeP/Rpa6ObDuUWPdVEzJjfyK+AqqYZYxZdV0l3+B13kTEmQ==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.57.0", + "@sentry/opentelemetry": "10.57.0", + "import-in-the-middle": "^3.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/core": "^1.30.1 || ^2.1.0", + "@opentelemetry/exporter-trace-otlp-http": ">=0.57.0 <1", + "@opentelemetry/instrumentation": ">=0.57.1 <1", + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", + "@opentelemetry/semantic-conventions": "^1.39.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@opentelemetry/core": { + "optional": true + }, + "@opentelemetry/exporter-trace-otlp-http": { + "optional": true + }, + "@opentelemetry/instrumentation": { + "optional": true + }, + "@opentelemetry/sdk-trace-base": { + "optional": true + }, + "@opentelemetry/semantic-conventions": { + "optional": true + } + } + }, + "node_modules/@sentry/opentelemetry": { + "version": "10.57.0", + "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.57.0.tgz", + "integrity": "sha512-iwRz8cEK0GOISG34aJRO8GdYOk3nfpuT6dT2GDQrxw8f7JjkJKx9LPU8MaenOFa4MhY+Z02hI6NNcrbsoI3cXg==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.57.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/core": "^1.30.1 || ^2.1.0", + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0", + "@opentelemetry/semantic-conventions": "^1.39.0" + } + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -1306,6 +1563,39 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@upstash/core-analytics": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@upstash/core-analytics/-/core-analytics-0.0.10.tgz", + "integrity": "sha512-7qJHGxpQgQr9/vmeS1PktEwvNAF7TI4iJDi8Pu2CFZ9YUGHZH4fOP5TfYlZ4aVxfopnELiE4BS4FBjyK7V1/xQ==", + "license": "MIT", + "dependencies": { + "@upstash/redis": "^1.28.3" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@upstash/ratelimit": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@upstash/ratelimit/-/ratelimit-2.0.6.tgz", + "integrity": "sha512-Uak5qklMfzFN5RXltxY6IXRENu+Hgmo9iEgMPOlUs2etSQas2N+hJfbHw37OUy4vldLRXeD0OzL+YRvO2l5acg==", + "license": "MIT", + "dependencies": { + "@upstash/core-analytics": "^0.0.10" + }, + "peerDependencies": { + "@upstash/redis": "^1.34.3" + } + }, + "node_modules/@upstash/redis": { + "version": "1.35.3", + "resolved": "https://registry.npmjs.org/@upstash/redis/-/redis-1.35.3.tgz", + "integrity": "sha512-hSjv66NOuahW3MisRGlSgoszU2uONAY2l5Qo3Sae8OT3/Tng9K+2/cBRuyPBX8egwEGcNNCF9+r0V6grNnhL+w==", + "license": "MIT", + "dependencies": { + "uncrypto": "^0.1.3" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -1323,7 +1613,6 @@ "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -1332,6 +1621,15 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-import-attributes": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", + "license": "MIT", + "peerDependencies": { + "acorn": "^8" + } + }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -1417,6 +1715,15 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1524,6 +1831,12 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/cjs-module-lexer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", + "license": "MIT" + }, "node_modules/cli-cursor": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", @@ -1581,7 +1894,6 @@ "version": "2.0.20", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", - "dev": true, "license": "MIT" }, "node_modules/commander": { @@ -1652,6 +1964,15 @@ "node": ">= 8" } }, + "node_modules/dateformat": { + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", + "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -1723,6 +2044,15 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/environment": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", @@ -2080,6 +2410,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/fast-copy": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.3.tgz", + "integrity": "sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2101,6 +2437,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2351,6 +2693,12 @@ "node": ">= 0.4" } }, + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", + "license": "MIT" + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -2426,6 +2774,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/import-in-the-middle": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.0.2.tgz", + "integrity": "sha512-LGLYRl0A2gtyUJb2WDliBHmk6TtlHwdDjxonacZ8QrEs/ZW+YDgNv2QAfjRQWpS8HqvNcq6GGnN6jrOa5FysDQ==", + "license": "Apache-2.0", + "dependencies": { + "acorn": "^8.15.0", + "acorn-import-attributes": "^1.9.5", + "cjs-module-lexer": "^2.2.0", + "module-details-from-path": "^1.0.4" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -2497,6 +2860,15 @@ "dev": true, "license": "ISC" }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/js-yaml": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", @@ -2775,6 +3147,21 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/module-details-from-path": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", + "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", @@ -2827,6 +3214,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -2839,6 +3235,15 @@ "node": ">= 0.8" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/onetime": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", @@ -2975,6 +3380,126 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-pretty": { + "version": "13.1.3", + "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.1.3.tgz", + "integrity": "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==", + "license": "MIT", + "dependencies": { + "colorette": "^2.0.7", + "dateformat": "^4.6.3", + "fast-copy": "^4.0.0", + "fast-safe-stringify": "^2.1.1", + "help-me": "^5.0.0", + "joycon": "^3.1.1", + "minimist": "^1.2.6", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pump": "^3.0.0", + "secure-json-parse": "^4.0.0", + "sonic-boom": "^4.0.1", + "strip-json-comments": "^5.0.2" + }, + "bin": { + "pino-pretty": "bin.js" + } + }, + "node_modules/pino-pretty/node_modules/strip-json-comments": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/playwright": { + "version": "1.51.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.51.1.tgz", + "integrity": "sha512-kkx+MB2KQRkyxjYPc3a0wLZZoDczmppyGJIvQ43l+aZihkaVvmu/21kiyaHeHjiFxjxNNFnUncKmcGIyOojsaw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.51.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.51.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.51.1.tgz", + "integrity": "sha512-/crRMj8+j/Nq5s8QcvegseuyeZPxpQCZb6HNk3Sos3BlZyAknRjoyJPFWkpNn8v0+P3WiwqFF8P+zQo4eqiNuw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -3001,6 +3526,22 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -3014,6 +3555,16 @@ "node": ">= 0.10" } }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3039,6 +3590,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -3063,6 +3620,51 @@ "node": ">= 0.8" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-in-the-middle": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", + "integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "module-details-from-path": "^1.0.3" + }, + "engines": { + "node": ">=9.3.0 || >=8.10.0 <9.0.0" + } + }, + "node_modules/require-in-the-middle/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/require-in-the-middle/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -3117,12 +3719,37 @@ ], "license": "MIT" }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/semver": { "version": "7.8.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", @@ -3325,6 +3952,24 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -3415,6 +4060,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", @@ -3561,6 +4224,12 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", + "license": "MIT" + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -3680,6 +4349,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/ws": { "version": "8.21.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", diff --git a/package.json b/package.json index 297d2d6..5cc4021 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "build": "npm --prefix web install && npm --prefix web run build", "typecheck": "tsc --noEmit && npm --prefix web run typecheck", "test": "node scripts/run-tests.mjs", + "test:e2e": "playwright test", "lint": "eslint .", "prepare": "husky" }, @@ -28,14 +29,23 @@ }, "dependencies": { "@asteasolutions/zod-to-openapi": "^8.5.0", + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/sdk-trace-node": "^2.7.1", "@scalar/express-api-reference": "^0.8.48", + "@sentry/node": "^10.57.0", + "@sentry/opentelemetry": "^10.57.0", + "@upstash/ratelimit": "^2.0.6", + "@upstash/redis": "^1.35.3", "express": "^4.21.2", + "pino": "^10.3.1", + "pino-pretty": "^13.1.3", "tsx": "^4.19.2", "ws": "^8.18.0", "zod": "^4.4.3" }, "devDependencies": { "@eslint/js": "^9.18.0", + "@playwright/test": "^1.51.1", "@types/express": "^4.17.21", "@types/node": "^22.10.5", "@types/ws": "^8.5.13", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..13fe249 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,24 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "e2e", + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: 1, + timeout: 60_000, + reporter: process.env.CI ? "github" : "html", + use: { + baseURL: "http://localhost:8080", + trace: "on-first-retry", + }, + projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], + ...(!process.env.CI && { + webServer: { + command: "npm start", + url: "http://localhost:8080/api/health", + reuseExistingServer: !process.env.PW_FRESH_SERVER, + env: { DEMO_MODE: "true" }, + }, + }), +}); diff --git a/src/application/use-cases/process-order-book-update.ts b/src/application/use-cases/process-order-book-update.ts index d4e1e14..f2279d7 100644 --- a/src/application/use-cases/process-order-book-update.ts +++ b/src/application/use-cases/process-order-book-update.ts @@ -1,5 +1,10 @@ import type { OrderBook } from "../../domain/entities/index.js"; import type { ArbitrageEngine } from "../../domain/services/arbitrage-engine.js"; +import { withSpan } from "../../instrumentation/otel.js"; +import { + newCorrelationId, + runWithCorrelation, +} from "../../infrastructure/logging/correlation.js"; export interface FeedRecorderPort { record(book: OrderBook): void; @@ -10,12 +15,30 @@ export class ProcessOrderBookUpdate { constructor( private readonly engine: ArbitrageEngine, private readonly recorder: FeedRecorderPort, - private readonly isExchangeActive: (exchange: OrderBook["exchange"]) => boolean, + private readonly isExchangeActive: ( + exchange: OrderBook["exchange"], + ) => boolean, ) {} run(book: OrderBook): void { if (!this.isExchangeActive(book.exchange)) return; - this.recorder.record(book); - this.engine.onBook(book); + + const correlationId = newCorrelationId("book"); + runWithCorrelation({ correlationId, exchange: book.exchange }, () => { + void withSpan( + "orderbook.process", + { exchange: book.exchange, correlationId }, + async () => { + this.recorder.record(book); + await withSpan( + "arbitrage.evaluate", + { exchange: book.exchange, correlationId }, + async () => { + this.engine.onBook(book); + }, + ); + }, + ); + }); } } diff --git a/src/index.ts b/src/index.ts index 1c5a367..926a3fc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,9 @@ +import { initInstrumentation } from "./instrumentation/index.js"; + +initInstrumentation(); + import express from "express"; +import * as Sentry from "@sentry/node"; import { apiReference } from "@scalar/express-api-reference"; import { existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; @@ -25,6 +30,7 @@ function main(): void { const server = express(); server.use(express.json()); server.use("/api", createRouter(ctx.application, sse)); + Sentry.setupExpressErrorHandler(server); server.use( "/api-docs", diff --git a/src/infrastructure/cache/upstash.ts b/src/infrastructure/cache/upstash.ts new file mode 100644 index 0000000..c573eb1 --- /dev/null +++ b/src/infrastructure/cache/upstash.ts @@ -0,0 +1,48 @@ +import { Redis } from "@upstash/redis"; + +const SNAPSHOT_KEY = "arbpulse:snapshot"; +const SNAPSHOT_TTL_SEC = 1; + +let redis: Redis | null | undefined; + +export function isUpstashConfigured(): boolean { + return Boolean( + process.env.UPSTASH_REDIS_REST_URL?.trim() && + process.env.UPSTASH_REDIS_REST_TOKEN?.trim(), + ); +} + +function getRedis(): Redis | null { + if (redis !== undefined) { + return redis; + } + const url = process.env.UPSTASH_REDIS_REST_URL?.trim(); + const token = process.env.UPSTASH_REDIS_REST_TOKEN?.trim(); + if (!url || !token) { + redis = null; + return null; + } + redis = new Redis({ url, token }); + return redis; +} + +export async function getCachedSnapshot(): Promise { + const client = getRedis(); + if (!client) return null; + try { + return (await client.get(SNAPSHOT_KEY)) ?? null; + } catch (err) { + console.warn("[upstash] get snapshot failed", err); + return null; + } +} + +export async function setCachedSnapshot(snapshot: T): Promise { + const client = getRedis(); + if (!client) return; + try { + await client.set(SNAPSHOT_KEY, snapshot, { ex: SNAPSHOT_TTL_SEC }); + } catch (err) { + console.warn("[upstash] set snapshot failed", err); + } +} diff --git a/src/infrastructure/exchanges/base.ts b/src/infrastructure/exchanges/base.ts index 1716fe4..5cc9398 100644 --- a/src/infrastructure/exchanges/base.ts +++ b/src/infrastructure/exchanges/base.ts @@ -1,5 +1,7 @@ import WebSocket from "ws"; +import * as Sentry from "@sentry/node"; import type { ExchangeId, OrderBook } from "../../domain/entities/index.js"; +import { withSpan } from "../../instrumentation/otel.js"; import { createLogger, type Logger } from "../logging/logger.js"; import type { MarketDataFeed } from "../../domain/ports/ports.js"; import { LocalBook } from "./local-book.js"; @@ -56,12 +58,16 @@ export abstract class ExchangeConnector implements MarketDataFeed { ws.on("open", () => { this.reconnectAttempts = 0; this.book.reset(); - this.log.info(this.skipSubscribe() ? "connected" : "connected, subscribing"); + this.log.info( + this.skipSubscribe() ? "connected" : "connected, subscribing", + ); if (!this.skipSubscribe()) { try { ws.send(JSON.stringify(this.subscribeMessage())); } catch (err) { - this.log.error("subscribe send failed", err); + this.log.error("subscribe send failed", { + error: err instanceof Error ? err.message : String(err), + }); } } this.startPing(); @@ -72,15 +78,27 @@ export abstract class ExchangeConnector implements MarketDataFeed { const text = data.toString(); // Some exchanges (OKX) reply to app-level pings with a plain "pong" frame. if (text === "pong" || text === "ping") return; - try { - this.handleMessage(JSON.parse(text)); - } catch (err) { - this.log.warn("failed to parse message", err); - } + void withSpan( + "ws.message", + { exchange: this.id, bytes: text.length }, + async () => { + try { + this.handleMessage(JSON.parse(text)); + } catch (err) { + this.log.warn("failed to parse message", { + error: err instanceof Error ? err.message : String(err), + }); + } + }, + ); }); ws.on("error", (err) => { - this.log.error("socket error", err instanceof Error ? err.message : err); + const error = err instanceof Error ? err : new Error(String(err)); + this.log.error("socket error", { error: error.message }); + Sentry.captureException(error, { + tags: { exchange: this.id, component: "ws" }, + }); }); ws.on("close", () => { @@ -97,8 +115,13 @@ export abstract class ExchangeConnector implements MarketDataFeed { private scheduleReconnect(): void { this.reconnectAttempts += 1; - const delay = Math.min(30_000, 500 * 2 ** Math.min(this.reconnectAttempts, 6)); - this.log.warn(`disconnected, reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`); + const delay = Math.min( + 30_000, + 500 * 2 ** Math.min(this.reconnectAttempts, 6), + ); + this.log.warn( + `disconnected, reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`, + ); setTimeout(() => { if (!this.closed) this.connect(); }, delay); diff --git a/src/infrastructure/logging/correlation.ts b/src/infrastructure/logging/correlation.ts new file mode 100644 index 0000000..76a6fc5 --- /dev/null +++ b/src/infrastructure/logging/correlation.ts @@ -0,0 +1,39 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import { randomUUID } from "node:crypto"; + +export interface CorrelationContext { + correlationId: string; + exchange?: string; +} + +const storage = new AsyncLocalStorage(); + +export function getCorrelationContext(): CorrelationContext | undefined { + return storage.getStore(); +} + +export function getCorrelationId(): string | undefined { + return storage.getStore()?.correlationId; +} + +export function runWithCorrelation( + context: CorrelationContext, + fn: () => T, +): T { + return storage.run(context, fn); +} + +export function newCorrelationId(prefix = "tick"): string { + return `${prefix}_${randomUUID().slice(0, 8)}`; +} + +export function bindRequestCorrelation( + headerValue: string | string[] | undefined, +): CorrelationContext { + const raw = Array.isArray(headerValue) ? headerValue[0] : headerValue; + const correlationId = + typeof raw === "string" && raw.trim() + ? raw.trim() + : newCorrelationId("req"); + return { correlationId }; +} diff --git a/src/infrastructure/logging/logger.ts b/src/infrastructure/logging/logger.ts index f1e9938..21a37cb 100644 --- a/src/infrastructure/logging/logger.ts +++ b/src/infrastructure/logging/logger.ts @@ -1,22 +1,38 @@ -type Level = "info" | "warn" | "error"; +import pino from "pino"; +import { getCorrelationContext } from "./correlation.js"; -function emit(level: Level, scope: string, msg: string, extra?: unknown): void { - const ts = new Date().toISOString(); - const line = `${ts} [${level.toUpperCase()}] (${scope}) ${msg}`; - if (level === "error") { - console.error(line, extra ?? ""); - } else if (level === "warn") { - console.warn(line, extra ?? ""); - } else { - console.log(line, extra ?? ""); - } -} +const root = pino({ + level: process.env.LOG_LEVEL ?? "info", + base: { service: "arbpulse" }, + timestamp: pino.stdTimeFunctions.isoTime, + ...(process.env.NODE_ENV !== "production" + ? { + transport: { + target: "pino-pretty", + options: { colorize: true, singleLine: true }, + }, + } + : {}), +}); export function createLogger(scope: string) { + const child = root.child({ scope }); return { - info: (msg: string, extra?: unknown) => emit("info", scope, msg, extra), - warn: (msg: string, extra?: unknown) => emit("warn", scope, msg, extra), - error: (msg: string, extra?: unknown) => emit("error", scope, msg, extra), + info: (msg: string, extra?: Record) => + child.info({ ...bindings(), ...extra }, msg), + warn: (msg: string, extra?: Record) => + child.warn({ ...bindings(), ...extra }, msg), + error: (msg: string, extra?: Record) => + child.error({ ...bindings(), ...extra }, msg), + }; +} + +function bindings(): Record { + const ctx = getCorrelationContext(); + if (!ctx) return {}; + return { + correlationId: ctx.correlationId, + ...(ctx.exchange ? { exchange: ctx.exchange } : {}), }; } diff --git a/src/instrumentation/index.ts b/src/instrumentation/index.ts new file mode 100644 index 0000000..e44fe7b --- /dev/null +++ b/src/instrumentation/index.ts @@ -0,0 +1,7 @@ +import { initOpenTelemetry } from "./otel.js"; +import { initSentry } from "./sentry.js"; + +export function initInstrumentation(): void { + initSentry(); + initOpenTelemetry(); +} diff --git a/src/instrumentation/otel.ts b/src/instrumentation/otel.ts new file mode 100644 index 0000000..5119d36 --- /dev/null +++ b/src/instrumentation/otel.ts @@ -0,0 +1,52 @@ +import * as Sentry from "@sentry/node"; +import { trace, type Span, type Tracer } from "@opentelemetry/api"; +import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"; +import { SentrySpanProcessor, SentryPropagator } from "@sentry/opentelemetry"; + +let tracer: Tracer | null = null; + +/** + * OpenTelemetry spans export to Sentry via @sentry/opentelemetry when SENTRY_DSN is set. + * Without a DSN, spans are no-ops (safe for local dev and tests). + */ +export function initOpenTelemetry(): Tracer { + if (tracer) return tracer; + + if (!process.env.SENTRY_DSN?.trim()) { + tracer = trace.getTracer("arbpulse"); + return tracer; + } + + const provider = new NodeTracerProvider({ + spanProcessors: [new SentrySpanProcessor()], + }); + provider.register({ + propagator: new SentryPropagator(), + }); + + tracer = provider.getTracer("arbpulse"); + return tracer; +} + +export function getTracer(): Tracer { + return tracer ?? initOpenTelemetry(); +} + +export async function withSpan( + name: string, + attributes: Record, + fn: (span: Span) => T | Promise, +): Promise { + const activeTracer = getTracer(); + return activeTracer.startActiveSpan(name, { attributes }, async (span) => { + try { + return await fn(span); + } catch (err) { + span.setStatus({ code: 2, message: String(err) }); + Sentry.captureException(err); + throw err; + } finally { + span.end(); + } + }); +} diff --git a/src/instrumentation/sentry.ts b/src/instrumentation/sentry.ts new file mode 100644 index 0000000..4edf1ba --- /dev/null +++ b/src/instrumentation/sentry.ts @@ -0,0 +1,26 @@ +import * as Sentry from "@sentry/node"; + +const dsn = process.env.SENTRY_DSN?.trim(); + +export function initSentry(): void { + if (!dsn) return; + + Sentry.init({ + dsn, + environment: process.env.NODE_ENV ?? "development", + tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1, + integrations: [Sentry.httpIntegration(), Sentry.expressIntegration()], + }); + + process.on("unhandledRejection", (reason) => { + Sentry.captureException( + reason instanceof Error ? reason : new Error(String(reason)), + ); + }); + + process.on("uncaughtException", (error) => { + Sentry.captureException(error); + }); +} + +export { Sentry }; diff --git a/src/interfaces/http/rate-limit.ts b/src/interfaces/http/rate-limit.ts new file mode 100644 index 0000000..ce2ae1e --- /dev/null +++ b/src/interfaces/http/rate-limit.ts @@ -0,0 +1,86 @@ +import { Ratelimit } from "@upstash/ratelimit"; +import { Redis } from "@upstash/redis"; +import type { NextFunction, Request, Response } from "express"; +import { isUpstashConfigured } from "../cache/upstash.js"; + +type RouteTier = "read" | "stream" | "write" | "health"; + +const LIMITS: Record< + Exclude, + { requests: number; window: `${number} s` | `${number} m` } +> = { + read: { requests: 60, window: "1 m" }, + stream: { requests: 10, window: "1 m" }, + write: { requests: 30, window: "1 m" }, +}; + +const limiters = new Map, Ratelimit>(); + +function getLimiter(tier: Exclude): Ratelimit | null { + if (!isUpstashConfigured()) { + return null; + } + let limiter = limiters.get(tier); + if (limiter) { + return limiter; + } + const url = process.env.UPSTASH_REDIS_REST_URL!.trim(); + const token = process.env.UPSTASH_REDIS_REST_TOKEN!.trim(); + const redis = new Redis({ url, token }); + const cfg = LIMITS[tier]; + limiter = new Ratelimit({ + redis, + limiter: Ratelimit.slidingWindow(cfg.requests, cfg.window), + prefix: `arbpulse:${tier}`, + }); + limiters.set(tier, limiter); + return limiter; +} + +function clientKey(req: Request): string { + const forwarded = req.header("x-forwarded-for"); + if (forwarded) { + return forwarded.split(",")[0]?.trim() || "unknown"; + } + return req.ip || req.socket.remoteAddress || "unknown"; +} + +function tierForPath(path: string, method: string): RouteTier { + if (path === "/health") return "health"; + if (path === "/stream") return "stream"; + if (method === "GET" || method === "HEAD") return "read"; + return "write"; +} + +export function createRateLimitMiddleware() { + return async ( + req: Request, + res: Response, + next: NextFunction, + ): Promise => { + const tier = tierForPath(req.path, req.method); + if (tier === "health") { + next(); + return; + } + + const limiter = getLimiter(tier); + if (!limiter) { + next(); + return; + } + + const { success, reset } = await limiter.limit(`${tier}:${clientKey(req)}`); + if (success) { + next(); + return; + } + + const retryAfterSec = Math.max(1, Math.ceil((reset - Date.now()) / 1000)); + res.setHeader("Retry-After", String(retryAfterSec)); + res.status(429).json({ + success: false, + error: "Too many requests", + }); + }; +} diff --git a/src/interfaces/http/routes.ts b/src/interfaces/http/routes.ts index 805b23a..8f035f1 100644 --- a/src/interfaces/http/routes.ts +++ b/src/interfaces/http/routes.ts @@ -1,6 +1,20 @@ -import { Router, type Request, type Response } from "express"; +import { + Router, + type NextFunction, + type Request, + type Response, +} from "express"; import type { ApplicationService } from "../../composition/application-service.js"; +import { + bindRequestCorrelation, + runWithCorrelation, +} from "../../infrastructure/logging/correlation.js"; +import { + getCachedSnapshot, + setCachedSnapshot, +} from "../../infrastructure/cache/upstash.js"; import type { SseHub } from "../sse/sse.js"; +import { createRateLimitMiddleware } from "./rate-limit.js"; import { ConfigPatchSchema, DemoControlBodySchema, @@ -13,12 +27,28 @@ import { parseBody } from "./validate.js"; export function createRouter(app: ApplicationService, sse: SseHub): Router { const router = Router(); + router.use((req: Request, res: Response, next: NextFunction) => { + const ctx = bindRequestCorrelation(req.header("x-request-id")); + res.setHeader("x-request-id", ctx.correlationId); + runWithCorrelation(ctx, () => next()); + }); + + router.use(createRateLimitMiddleware()); + router.get("/health", (_req: Request, res: Response) => { res.json({ success: true, data: { status: "ok", ts: Date.now() } }); }); - router.get("/state", (_req: Request, res: Response) => { - res.json({ success: true, data: app.getSnapshot() }); + router.get("/state", async (_req: Request, res: Response) => { + const cached = + await getCachedSnapshot>(); + if (cached) { + res.json({ success: true, data: cached }); + return; + } + const snapshot = app.getSnapshot(); + void setCachedSnapshot(snapshot); + res.json({ success: true, data: snapshot }); }); router.get("/stream", (req: Request, res: Response) => { diff --git a/src/interfaces/sse/sse.ts b/src/interfaces/sse/sse.ts index cfc8f85..92d0e07 100644 --- a/src/interfaces/sse/sse.ts +++ b/src/interfaces/sse/sse.ts @@ -1,6 +1,9 @@ import type { Request, Response } from "express"; +import * as Sentry from "@sentry/node"; import { config } from "../../infrastructure/config/config.js"; +import { setCachedSnapshot } from "../../infrastructure/cache/upstash.js"; import { createLogger } from "../../infrastructure/logging/logger.js"; +import { withSpan } from "../../instrumentation/otel.js"; import type { ApplicationService } from "../../composition/application-service.js"; const log = createLogger("sse"); @@ -22,26 +25,58 @@ export class SseHub { } handle(req: Request, res: Response): void { - res.writeHead(200, { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache, no-transform", - Connection: "keep-alive", - "X-Accel-Buffering": "no", - }); - res.write(`retry: 2000\n\n`); - this.send(res, this.app.getSnapshot()); - this.clients.add(res); - log.info(`client connected (${this.clients.size} total)`); + try { + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + "X-Accel-Buffering": "no", + }); + res.write(`retry: 2000\n\n`); + this.send(res, this.app.getSnapshot()); + this.clients.add(res); + log.info(`client connected`, { clientCount: this.clients.size }); - req.on("close", () => { - this.clients.delete(res); - }); + req.on("close", () => { + this.clients.delete(res); + }); + + res.on("error", (err) => { + const error = err instanceof Error ? err : new Error(String(err)); + log.error("sse client error", { error: error.message }); + Sentry.captureException(error, { tags: { component: "sse" } }); + this.clients.delete(res); + }); + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + log.error("sse handle failed", { error: error.message }); + Sentry.captureException(error, { tags: { component: "sse" } }); + if (!res.headersSent) { + res.status(500).json({ success: false, error: "SSE setup failed" }); + } + } } private broadcast(): void { if (this.clients.size === 0) return; - const snapshot = this.app.getSnapshot(); - for (const res of this.clients) this.send(res, snapshot); + void withSpan( + "sse.broadcast", + { clientCount: this.clients.size }, + async () => { + const snapshot = this.app.getSnapshot(); + void setCachedSnapshot(snapshot); + for (const res of this.clients) { + try { + this.send(res, snapshot); + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + log.warn("sse send failed", { error: error.message }); + Sentry.captureException(error, { tags: { component: "sse" } }); + this.clients.delete(res); + } + } + }, + ); } private send(res: Response, data: unknown): void { diff --git a/web/package-lock.json b/web/package-lock.json index 38deb77..f2d8053 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -28,14 +28,23 @@ "version": "1.0.0", "dependencies": { "@asteasolutions/zod-to-openapi": "^8.5.0", + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/sdk-trace-node": "^2.7.1", "@scalar/express-api-reference": "^0.8.48", + "@sentry/node": "^10.57.0", + "@sentry/opentelemetry": "^10.57.0", + "@upstash/ratelimit": "^2.0.6", + "@upstash/redis": "^1.35.3", "express": "^4.21.2", + "pino": "^10.3.1", + "pino-pretty": "^13.1.3", "tsx": "^4.19.2", "ws": "^8.18.0", "zod": "^4.4.3" }, "devDependencies": { "@eslint/js": "^9.18.0", + "@playwright/test": "^1.51.1", "@types/express": "^4.17.21", "@types/node": "^22.10.5", "@types/ws": "^8.5.13",