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>
This commit is contained in:
@@ -8,6 +8,11 @@
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
vi.mock('../../logger.js', () => ({
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), fatal: vi.fn() },
|
||||
}));
|
||||
import { logger } from '../../logger.js';
|
||||
|
||||
// ── mock the drizzle query chain: db.select(...).from(...).where(...) ────────
|
||||
let users: { userId: string }[] = [];
|
||||
const whereMock = vi.fn(async () => users);
|
||||
@@ -35,6 +40,7 @@ beforeEach(() => {
|
||||
whereMock.mockClear();
|
||||
fromMock.mockClear();
|
||||
selectMock.mockClear();
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
@@ -102,8 +108,6 @@ describe('startTodoistSyncScheduler', () => {
|
||||
if (id === 'bad') throw new Error('todoist 401');
|
||||
return [];
|
||||
});
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
startTodoistSyncScheduler(60_000);
|
||||
await vi.advanceTimersByTimeAsync(10_001);
|
||||
@@ -112,19 +116,27 @@ describe('startTodoistSyncScheduler', () => {
|
||||
await Promise.resolve();
|
||||
|
||||
expect(fetchSignalsMock).toHaveBeenCalledTimes(3);
|
||||
expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('sync error'), expect.anything());
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('2 ok, 1 failed'));
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.anything() }),
|
||||
'scheduler: sync error',
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ ok: 2, failed: 1 }),
|
||||
'scheduler: todoist sync',
|
||||
);
|
||||
});
|
||||
|
||||
it('survives a db query failure — logs and skips the tick', async () => {
|
||||
const { startTodoistSyncScheduler } = await import('../scheduler.js');
|
||||
whereMock.mockRejectedValueOnce(new Error('sqlite locked'));
|
||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
startTodoistSyncScheduler(60_000);
|
||||
await vi.advanceTimersByTimeAsync(10_001);
|
||||
|
||||
expect(fetchSignalsMock).not.toHaveBeenCalled();
|
||||
expect(errSpy).toHaveBeenCalledWith(expect.stringContaining('failed to query users'));
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: expect.anything() }),
|
||||
'scheduler: failed to query users',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Signal, SignalSource } from '@oo/shared-types';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
/**
|
||||
* Merges signals from all registered sources for a user.
|
||||
@@ -24,7 +25,7 @@ export class SignalAggregator {
|
||||
if (r.status === 'fulfilled') {
|
||||
signals.push(...r.value);
|
||||
} else {
|
||||
console.error(`[aggregator] source '${this.sources[i].id}' failed:`, r.reason);
|
||||
logger.error({ sourceId: this.sources[i]!.id, err: r.reason }, 'aggregator: source failed');
|
||||
}
|
||||
}
|
||||
return signals;
|
||||
|
||||
@@ -13,6 +13,7 @@ import { db } from '../db/index.js';
|
||||
import { integrationTokens } from '../db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { todoistSource } from './todoist.js';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
const DEFAULT_INTERVAL_MS = 15 * 60 * 1000;
|
||||
|
||||
@@ -25,7 +26,7 @@ export function startTodoistSyncScheduler(intervalMs = DEFAULT_INTERVAL_MS): Nod
|
||||
.from(integrationTokens)
|
||||
.where(eq(integrationTokens.tokenStatus, 'active'));
|
||||
} catch (err: any) {
|
||||
console.error(`[scheduler] failed to query users: ${err.message}`);
|
||||
logger.error({ err }, 'scheduler: failed to query users');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -39,10 +40,10 @@ export function startTodoistSyncScheduler(intervalMs = DEFAULT_INTERVAL_MS): Nod
|
||||
let failed = 0;
|
||||
for (const r of results) {
|
||||
if (r.status === 'fulfilled') ok++;
|
||||
else { failed++; console.error(`[scheduler] sync error:`, r.reason); }
|
||||
else { failed++; logger.error({ err: r.reason }, 'scheduler: sync error'); }
|
||||
}
|
||||
|
||||
console.log(`[scheduler] todoist sync: ${ok} ok, ${failed} failed (${users.length} users)`);
|
||||
logger.info({ ok, failed, total: users.length }, 'scheduler: todoist sync');
|
||||
}
|
||||
|
||||
// Run once shortly after startup, then on interval
|
||||
|
||||
@@ -3,6 +3,7 @@ import { db } from '../db/index.js';
|
||||
import { integrationTokens } from '../db/schema.js';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { bus } from '../events/bus.js';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
const CACHE_TTL_MS = 30_000;
|
||||
|
||||
@@ -46,7 +47,7 @@ export class TodoistSignalSource implements SignalSource {
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
console.error(`[todoist] token expired for user ${userId}`);
|
||||
logger.warn({ userId }, 'todoist: token expired');
|
||||
bus.publish('signals.integration.token_expired', {
|
||||
userId,
|
||||
provider: 'todoist',
|
||||
|
||||
Reference in New Issue
Block a user