Add Sentry, Pino logs, and OTel spans on WS pipeline (M2).

Capture REST/WS/SSE errors in Sentry, emit structured pino logs with correlation IDs, and trace the book-tick pipeline to Sentry via OpenTelemetry.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Mauricio Barragan
2026-06-11 01:01:20 -06:00
parent f4def0c214
commit fd4170963e
22 changed files with 1280 additions and 50 deletions
+7 -1
View File
@@ -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=
+18
View File
@@ -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
+30
View File
@@ -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
+45
View File
@@ -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
+4 -1
View File
@@ -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.
---
+19
View File
@@ -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();
});
+677 -2
View File
@@ -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",
+10
View File
@@ -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",
+24
View File
@@ -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" },
},
}),
});
@@ -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;
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);
},
);
},
);
});
}
}
+6
View File
@@ -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",
+48
View File
@@ -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<T>(): Promise<T | null> {
const client = getRedis();
if (!client) return null;
try {
return (await client.get<T>(SNAPSHOT_KEY)) ?? null;
} catch (err) {
console.warn("[upstash] get snapshot failed", err);
return null;
}
}
export async function setCachedSnapshot<T>(snapshot: T): Promise<void> {
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);
}
}
+29 -6
View File
@@ -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;
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", 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);
+39
View File
@@ -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<CorrelationContext>();
export function getCorrelationContext(): CorrelationContext | undefined {
return storage.getStore();
}
export function getCorrelationId(): string | undefined {
return storage.getStore()?.correlationId;
}
export function runWithCorrelation<T>(
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 };
}
+30 -14
View File
@@ -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<string, unknown>) =>
child.info({ ...bindings(), ...extra }, msg),
warn: (msg: string, extra?: Record<string, unknown>) =>
child.warn({ ...bindings(), ...extra }, msg),
error: (msg: string, extra?: Record<string, unknown>) =>
child.error({ ...bindings(), ...extra }, msg),
};
}
function bindings(): Record<string, unknown> {
const ctx = getCorrelationContext();
if (!ctx) return {};
return {
correlationId: ctx.correlationId,
...(ctx.exchange ? { exchange: ctx.exchange } : {}),
};
}
+7
View File
@@ -0,0 +1,7 @@
import { initOpenTelemetry } from "./otel.js";
import { initSentry } from "./sentry.js";
export function initInstrumentation(): void {
initSentry();
initOpenTelemetry();
}
+52
View File
@@ -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<T>(
name: string,
attributes: Record<string, string | number | boolean>,
fn: (span: Span) => T | Promise<T>,
): Promise<T> {
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();
}
});
}
+26
View File
@@ -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 };
+86
View File
@@ -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<RouteTier, "health">,
{ 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<Exclude<RouteTier, "health">, Ratelimit>();
function getLimiter(tier: Exclude<RouteTier, "health">): 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<void> => {
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",
});
};
}
+33 -3
View File
@@ -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<ReturnType<ApplicationService["getSnapshot"]>>();
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) => {
+37 -2
View File
@@ -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,6 +25,7 @@ export class SseHub {
}
handle(req: Request, res: Response): void {
try {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
@@ -31,17 +35,48 @@ export class SseHub {
res.write(`retry: 2000\n\n`);
this.send(res, this.app.getSnapshot());
this.clients.add(res);
log.info(`client connected (${this.clients.size} total)`);
log.info(`client connected`, { clientCount: this.clients.size });
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;
void withSpan(
"sse.broadcast",
{ clientCount: this.clients.size },
async () => {
const snapshot = this.app.getSnapshot();
for (const res of this.clients) this.send(res, snapshot);
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 {
+9
View File
@@ -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",