Skip to content

Commit b38f208

Browse files
authored
Merge pull request #1173 from jotel-dev/845-backend-background-indexer-worker-logs-carry-no-correlation-id
845 backend background indexer worker logs carry no correlation
2 parents 70ee6dd + 6706cdd commit b38f208

15 files changed

Lines changed: 342 additions & 1183 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ jobs:
4141
# Frontend devDeps do not list @vitest/coverage-v8 (mirrors the
4242
# backend workflow), so install it ad-hoc before coverage runs.
4343
- name: Install Vitest Coverage Provider
44-
run: npm install @vitest/coverage-v8@2.1.9 --no-save
44+
run: npm install @vitest/coverage-v8@3.2.7 --no-save
4545
working-directory: frontend
4646

4747
- name: Run Frontend Tests

.github/workflows/pr-test-gate.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ jobs:
6868
# (where vitest actually resolves it from) rather than the root.
6969
- name: Install Vitest + Native Bindings
7070
run: |
71-
npm install @vitest/coverage-v8@2.1.9 --no-save
71+
npm install @vitest/coverage-v8@3.2.7 --no-save
7272
npm install @rollup/rollup-linux-x64-gnu --no-save
7373
working-directory: backend
7474

backend/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,10 @@ All REST API endpoints are prefixed with `/v1`. Refer to the API Documentation i
8484
## Server-Sent Events (SSE)
8585

8686
The backend exposes an SSE endpoint (`/v1/streams/events`) to stream real-time updates to the frontend whenever on-chain stream events are indexed.
87+
88+
## Logging & Correlation IDs
89+
90+
Structured JSON logs generated by Winston (`backend/src/logger.ts`) attach a `requestId` correlation ID via `AsyncLocalStorage` (`requestContext`):
91+
- **HTTP Requests:** Set via `requestIdMiddleware` (`X-Request-ID` header or auto-generated UUID).
92+
- **Worker Poll Batches:** Each poll cycle in `SorobanEventWorker` runs in `requestContext.run({ requestId: randomUUID() }, ...)` so all event logs and error traces share a single ID per cycle.
93+
- **Admin Replays:** Replays triggered via `replayFromLedger` / `POST /v1/admin/indexer/replay` execute under a shared `requestId` that is included in all indexer log statements and returned in the HTTP 202 JSON response.

backend/src/routes/v1/admin.routes.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -269,8 +269,8 @@ router.post('/indexer/replay', async (req: Request, res: Response) => {
269269
return;
270270
}
271271
try {
272-
await replayFromLedger(fromLedger);
273-
res.status(202).json({ ok: true, replayingFrom: fromLedger });
272+
const requestId = await replayFromLedger(fromLedger);
273+
res.status(202).json({ ok: true, replayingFrom: fromLedger, requestId });
274274
} catch (err) {
275275
res.status(500).json({ error: 'Replay failed' });
276276
}

backend/src/services/indexerService.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,11 @@
1616
* `indexer.service.ts` so every service is kebab-case with a `.service.ts`
1717
* suffix.
1818
*/
19+
import { randomUUID } from 'crypto';
1920
import { prisma } from '../lib/prisma.js';
2021
import { INDEXER_STATE_ID } from '../lib/indexer-state.js';
2122
import { sorobanEventWorker } from '../workers/soroban-event-worker.js';
22-
import logger from '../logger.js';
23+
import logger, { requestContext } from '../logger.js';
2324

2425
export interface IndexerStatus {
2526
lastLedger: number;
@@ -63,10 +64,24 @@ export async function resetIndexer(toLedger: number): Promise<void> {
6364
* Stream.withdrawnAmount (handleTokensWithdrawn, soroban-event-worker.ts:635)
6465
* is incremented unconditionally on every replay, so replay is NOT fully
6566
* idempotent. See issue #808 for the withdrawnAmount idempotency fix.
67+
*
68+
* @param fromLedger Starting ledger sequence to replay from
69+
* @param customRequestId Optional correlation ID to bind logs to
70+
* @returns The correlation requestId associated with this replay cycle
6671
*/
67-
export async function replayFromLedger(fromLedger: number): Promise<void> {
68-
await resetIndexer(fromLedger);
69-
// Kick off an immediate poll cycle without waiting for the next interval.
70-
await sorobanEventWorker.triggerPoll();
71-
logger.info(`[IndexerService] Replay triggered from ledger ${fromLedger}`);
72+
export async function replayFromLedger(
73+
fromLedger: number,
74+
customRequestId?: string,
75+
): Promise<string> {
76+
const requestId =
77+
customRequestId || requestContext.getStore()?.requestId || randomUUID();
78+
79+
return requestContext.run({ requestId }, async () => {
80+
await resetIndexer(fromLedger);
81+
// Kick off an immediate poll cycle without waiting for the next interval.
82+
await sorobanEventWorker.triggerPoll(requestId);
83+
logger.info(`[IndexerService] Replay triggered from ledger ${fromLedger}`);
84+
return requestId;
85+
});
7286
}
87+

backend/src/workers/soroban-event-worker.ts

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1+
import { randomUUID } from "crypto";
12
import { rpc, xdr, StrKey } from "@stellar/stellar-sdk";
23
import { prisma } from "../lib/prisma.js";
34
import { INDEXER_STATE_ID, ensureIndexerState } from "../lib/indexer-state.js";
45
import { sseService } from "../services/sse.service.js";
5-
import logger from "../logger.js";
6+
import logger, { requestContext } from "../logger.js";
67
import { Prisma } from "../generated/prisma/index.js";
78
import "../lib/stream-id.js";
89

@@ -198,15 +199,30 @@ export class SorobanEventWorker {
198199
* Trigger an immediate poll cycle (used for replay and manual updates).
199200
* Serialized with the scheduled poll via `runExclusive` so two cursor writes
200201
* cannot overlap and regress `lastCursor` (#843).
202+
*
203+
* @param customRequestId Optional correlation ID to bind logs to a specific request/replay.
204+
* @returns The correlation requestId associated with this poll batch.
201205
*/
202-
async triggerPoll(): Promise<void> {
203-
if (!this.isRunning) return;
206+
async triggerPoll(customRequestId?: string): Promise<string> {
207+
if (!this.isRunning) {
208+
return customRequestId || requestContext?.getStore?.()?.requestId || randomUUID();
209+
}
210+
211+
const requestId =
212+
customRequestId || requestContext?.getStore?.()?.requestId || randomUUID();
204213

205214
try {
206-
await this.runExclusive(() => this.fetchAndProcessEvents());
215+
await this.runExclusive(() => {
216+
const runBatch = () => this.fetchAndProcessEvents();
217+
return requestContext && typeof requestContext.run === 'function'
218+
? requestContext.run({ requestId }, runBatch)
219+
: runBatch();
220+
});
207221
} catch (err) {
208222
logger.error("[SorobanWorker] Manual poll error:", err);
209223
}
224+
225+
return requestId;
210226
}
211227

212228
// ─── Internal ──────────────────────────────────────────────────────────────
@@ -270,11 +286,16 @@ export class SorobanEventWorker {
270286

271287
private async poll(): Promise<void> {
272288
try {
273-
await this.runExclusive(() =>
274-
this.fetchAndProcessEvents().catch((err) => {
275-
logger.error("[SorobanWorker] Unhandled error during poll:", err);
276-
}),
277-
);
289+
const requestId = randomUUID();
290+
await this.runExclusive(() => {
291+
const execute = () =>
292+
this.fetchAndProcessEvents().catch((err) => {
293+
logger.error("[SorobanWorker] Unhandled error during poll:", err);
294+
});
295+
return requestContext && typeof requestContext.run === 'function'
296+
? requestContext.run({ requestId }, execute)
297+
: execute();
298+
});
278299
} finally {
279300
this.scheduleNext();
280301
}
@@ -285,6 +306,14 @@ export class SorobanEventWorker {
285306
* cursor (or start ledger on first run) and process each one in order.
286307
*/
287308
private async fetchAndProcessEvents(): Promise<void> {
309+
const currentCtx = requestContext?.getStore?.();
310+
if (!currentCtx?.requestId && requestContext && typeof requestContext.run === 'function') {
311+
const requestId = randomUUID();
312+
return requestContext.run({ requestId }, () =>
313+
this.fetchAndProcessEvents(),
314+
);
315+
}
316+
288317
// Ensure an IndexerState row exists on first run.
289318
const state = await ensureIndexerState(this.startLedger);
290319

backend/tests/indexer-service.test.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,16 @@ vi.mock('../src/workers/soroban-event-worker.js', () => ({
1515
},
1616
}));
1717

18-
vi.mock('../src/logger.js', () => ({
19-
default: {
20-
info: vi.fn(),
21-
error: vi.fn(),
22-
},
23-
}));
18+
vi.mock('../src/logger.js', async (importOriginal) => {
19+
const actual = await importOriginal<typeof import('../src/logger.js')>();
20+
return {
21+
...actual,
22+
default: {
23+
info: vi.fn(),
24+
error: vi.fn(),
25+
},
26+
};
27+
});
2428

2529
import { prisma } from '../src/lib/prisma.js';
2630
import { sorobanEventWorker } from '../src/workers/soroban-event-worker.js';

backend/tests/soroban-event-worker.test.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,17 @@ vi.mock('../src/services/sse.service.js', () => ({
3939
}));
4040

4141
// Mock logger
42-
vi.mock('../src/logger.js', () => ({
43-
default: {
44-
info: vi.fn(),
45-
warn: vi.fn(),
46-
error: vi.fn(),
47-
},
48-
}));
42+
vi.mock('../src/logger.js', async (importOriginal) => {
43+
const actual = await importOriginal<typeof import('../src/logger.js')>();
44+
return {
45+
...actual,
46+
default: {
47+
info: vi.fn(),
48+
warn: vi.fn(),
49+
error: vi.fn(),
50+
},
51+
};
52+
});
4953

5054
import { SorobanEventWorker } from '../src/workers/soroban-event-worker.js';
5155
import { prisma } from '../src/lib/prisma.js';
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { requestContext } from '../src/logger.js';
3+
import { SorobanEventWorker } from '../src/workers/soroban-event-worker.js';
4+
import { replayFromLedger } from '../src/services/indexerService.js';
5+
6+
vi.mock('../src/lib/prisma.js', () => ({
7+
prisma: {
8+
indexerState: {
9+
findUnique: vi.fn().mockResolvedValue({ lastLedger: 100, lastCursor: 'c1' }),
10+
upsert: vi.fn().mockResolvedValue({ id: 'singleton', lastLedger: 100, lastCursor: 'c1' }),
11+
},
12+
},
13+
}));
14+
15+
vi.mock('@stellar/stellar-sdk', () => {
16+
return {
17+
rpc: {
18+
Server: vi.fn().mockImplementation(() => ({
19+
getEvents: vi.fn().mockResolvedValue({ events: [] }),
20+
})),
21+
},
22+
xdr: {
23+
ScVal: vi.fn(),
24+
},
25+
StrKey: {},
26+
};
27+
});
28+
29+
describe('Worker and Replay Correlation ID', () => {
30+
beforeEach(() => {
31+
vi.clearAllMocks();
32+
});
33+
34+
it('binds worker poll execution to a requestId inside requestContext', async () => {
35+
const worker = new SorobanEventWorker();
36+
let capturedStoreRequestId: string | undefined;
37+
38+
// Trigger poll manually
39+
const reqId = await worker.triggerPoll('test-correlation-id-123');
40+
41+
expect(reqId).toBe('test-correlation-id-123');
42+
43+
// Inside a requestContext.run block, requestContext.getStore() should be accessible if invoked
44+
requestContext.run({ requestId: 'custom-check-id' }, () => {
45+
capturedStoreRequestId = requestContext.getStore()?.requestId;
46+
});
47+
expect(capturedStoreRequestId).toBe('custom-check-id');
48+
});
49+
50+
it('replayFromLedger returns correlation requestId and binds worker poll to it', async () => {
51+
const workerSpy = vi.spyOn(SorobanEventWorker.prototype, 'triggerPoll');
52+
53+
const resultRequestId = await replayFromLedger(50, 'replay-req-456');
54+
55+
expect(resultRequestId).toBe('replay-req-456');
56+
expect(workerSpy).toHaveBeenCalledWith('replay-req-456');
57+
});
58+
59+
it('automatically generates a correlation requestId if none is provided to replayFromLedger', async () => {
60+
const workerSpy = vi.spyOn(SorobanEventWorker.prototype, 'triggerPoll');
61+
62+
const resultRequestId = await replayFromLedger(50);
63+
64+
expect(resultRequestId).toBeDefined();
65+
expect(typeof resultRequestId).toBe('string');
66+
expect(resultRequestId.length).toBeGreaterThan(0);
67+
expect(workerSpy).toHaveBeenCalledWith(resultRequestId);
68+
});
69+
});

docs/ARCHITECTURE.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,8 +165,18 @@ Benefits:
165165
## Operational Notes
166166

167167
1. `/v1/events/stats` exposes active SSE connections and connection-capacity metrics.
168-
1. Admin metrics include SSE peak-per-IP visibility for abuse monitoring.
169-
1. User summary endpoint (`/v1/users/{address}/summary`) is cached for 30s to protect DB hot paths.
168+
2. Admin metrics include SSE peak-per-IP visibility for abuse monitoring.
169+
3. User summary endpoint (`/v1/users/{address}/summary`) is cached for 30s to protect DB hot paths.
170+
171+
---
172+
173+
## Logging & Observability
174+
175+
All backend log lines use standard JSON formatting via Winston and include a `requestId` correlation ID field when running inside a request or worker context (managed by Node's `AsyncLocalStorage` via `requestContext` in `backend/src/logger.ts`).
176+
177+
- **HTTP Requests:** Requests receive or generate a `requestId` via `requestIdMiddleware` (`X-Request-ID` header).
178+
- **Background Indexer/Worker Poll Cycles:** Each `SorobanEventWorker` poll batch runs inside `requestContext.run({ requestId: randomUUID() }, ...)` so all RPC fetches, event processing, and per-event error logs within that poll cycle share a single correlation ID.
179+
- **Admin Replays:** Triggering an indexer event replay (via `replayFromLedger` or `POST /v1/admin/indexer/replay`) wraps the reset and worker poll cycle in `requestContext`. The correlation ID is included on all log statements emitted during replay and returned in the HTTP API response (`{ ok: true, replayingFrom: <ledger>, requestId: "<id>" }`).
170180

171181
---
172182

0 commit comments

Comments
 (0)