mirror of
https://github.com/mauricioabh/arbpulse.git
synced 2026-07-27 15:47:43 +00:00
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:
+7
-1
@@ -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=
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
Generated
+677
-2
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
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);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Vendored
+48
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { initOpenTelemetry } from "./otel.js";
|
||||
import { initSentry } from "./sentry.js";
|
||||
|
||||
export function initInstrumentation(): void {
|
||||
initSentry();
|
||||
initOpenTelemetry();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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",
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
+50
-15
@@ -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 {
|
||||
|
||||
Generated
+9
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user