Files
oO/services/api/src/events/nats.ts
alvis c4960d0601 feat(observability): structured logs, W3C trace IDs, Sentry hooks (#18)
- TS: pino + pino-http; every HTTP request log includes traceId from
  W3C traceparent header (generated if absent); forwarded to ml/serving
  on all /score, /generate, /reward, and /api/ml proxy calls
- Python: structlog JSON; FastAPI middleware binds trace_id via
  contextvars so every log line within a request carries it
- Sentry: optional SENTRY_DSN init in both runtimes (no-op if unset)
- Replace all console.* calls across services/api with pino logger
- Update tests to spy on logger instead of console

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 03:37:28 +00:00

86 lines
2.5 KiB
TypeScript

/**
* Optional NATS JetStream adapter.
*
* When NATS_URL is set: connects to NATS, creates the required streams on
* startup, and wraps the in-process Bus so every publish also goes to
* JetStream. Subscribers across processes (ml/serving, future services)
* consume from JetStream.
*
* When NATS_URL is not set: this module is a no-op and the in-process Bus
* works as before.
*/
import type { NatsConnection, JetStreamClient, StreamConfig } from 'nats';
import { bus } from './bus.js';
import { logger } from '../logger.js';
let nc: NatsConnection | null = null;
let js: JetStreamClient | null = null;
// nats enums are string enums — import the values at runtime, not just types
async function getStreamConfigs(): Promise<Partial<StreamConfig>[]> {
const { StorageType: S, RetentionPolicy: R, DiscardPolicy: D } = await import('nats');
return [
{
name: 'signals',
subjects: ['signals.>'],
max_msgs: 500_000,
max_age: 7 * 24 * 60 * 60 * 1e9, // 7 days in nanoseconds
storage: S.File,
retention: R.Limits,
discard: D.Old,
num_replicas: 1,
duplicate_window: 120e9,
},
{
name: 'feedback',
subjects: ['feedback.>'],
max_msgs: 200_000,
max_age: 30 * 24 * 60 * 60 * 1e9,
storage: S.File,
retention: R.Limits,
discard: D.Old,
num_replicas: 1,
duplicate_window: 120e9,
},
];
}
export async function connectNats(natsUrl: string): Promise<void> {
try {
const { connect } = await import('nats');
nc = await connect({ servers: natsUrl, reconnect: true, maxReconnectAttempts: -1 });
js = nc.jetstream();
// Ensure streams exist (idempotent)
const STREAMS = await getStreamConfigs();
const jsm = await nc.jetstreamManager();
for (const cfg of STREAMS) {
try {
await jsm.streams.info(cfg.name!);
} catch {
await jsm.streams.add(cfg as StreamConfig);
}
}
// Bridge: every in-process bus publish also goes to JetStream
bus.onPublish((subject, payload) => {
if (!js) return;
const data = new TextEncoder().encode(JSON.stringify(payload));
js.publish(subject, data).catch((err: Error) =>
logger.error({ err, subject }, 'nats publish failed'),
);
});
logger.info({ url: natsUrl, streams: STREAMS.map((s) => s.name) }, 'nats connected');
} catch (err: any) {
logger.warn({ err }, 'nats connection failed — running without JetStream');
}
}
export async function closeNats(): Promise<void> {
await nc?.drain();
nc = null;
js = null;
}