mirror of
https://github.com/mauricioabh/arbpulse.git
synced 2026-08-02 10:17:45 +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:
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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user