From c31f51ddbdb2e82337fd506b22b612937c08e5b7 Mon Sep 17 00:00:00 2001 From: Saravana Achu Mac Date: Tue, 5 May 2026 11:06:02 -0700 Subject: [PATCH] fix(backend): propagate outbound request ids --- backend/src/lib/extraction-client.test.ts | 22 ++++++++++++++++++++++ backend/src/lib/extraction-client.ts | 4 +++- backend/src/lib/field-encrypt.test.ts | 23 +++++++++++++++++++++++ backend/src/lib/field-encrypt.ts | 5 +++-- backend/src/lib/request-context.test.ts | 22 ++++++++++++++++++++++ backend/src/lib/request-context.ts | 22 ++++++++++++++++++++++ backend/src/modules/notes/routes.ts | 3 ++- backend/src/server.test.ts | 6 +++++- backend/src/server.ts | 4 ++-- 9 files changed, 104 insertions(+), 7 deletions(-) create mode 100644 backend/src/lib/extraction-client.test.ts create mode 100644 backend/src/lib/field-encrypt.test.ts create mode 100644 backend/src/lib/request-context.test.ts diff --git a/backend/src/lib/extraction-client.test.ts b/backend/src/lib/extraction-client.test.ts new file mode 100644 index 0000000..58e3780 --- /dev/null +++ b/backend/src/lib/extraction-client.test.ts @@ -0,0 +1,22 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { extractFromText } from './extraction-client.js'; + +describe('extraction client request propagation', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('sends x-request-id to extraction-service when provided', async () => { + const fetchMock = vi.fn(async () => new Response(JSON.stringify({ summary: 'ok' }), { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + + await extractFromText('hello', 'summarization', { requestId: 'req-propagated' }); + + expect(fetchMock).toHaveBeenCalledOnce(); + const init = fetchMock.mock.calls[0]?.[1] as RequestInit; + expect(init.headers).toMatchObject({ + 'Content-Type': 'application/json', + 'x-request-id': 'req-propagated', + }); + }); +}); diff --git a/backend/src/lib/extraction-client.ts b/backend/src/lib/extraction-client.ts index 4f3b31c..f755525 100644 --- a/backend/src/lib/extraction-client.ts +++ b/backend/src/lib/extraction-client.ts @@ -1,4 +1,5 @@ import { config } from './config.js'; +import { requestIdHeaders } from './request-context.js'; export interface ExtractionResult { summary?: string; @@ -9,12 +10,13 @@ export interface ExtractionResult { export async function extractFromText( text: string, taskType: string, + options: { requestId?: string } = {}, ): Promise { const url = `${config.EXTRACTION_SERVICE_URL}/api/extract`; const res = await fetch(url, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...requestIdHeaders(options.requestId) }, body: JSON.stringify({ text, task: taskType }), }); diff --git a/backend/src/lib/field-encrypt.test.ts b/backend/src/lib/field-encrypt.test.ts new file mode 100644 index 0000000..94c656b --- /dev/null +++ b/backend/src/lib/field-encrypt.test.ts @@ -0,0 +1,23 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { initEncryption, _resetEncryptor } from './field-encrypt.js'; + +describe('field encryption platform flag polling', () => { + afterEach(() => { + vi.unstubAllGlobals(); + _resetEncryptor(); + }); + + it('propagates x-request-id when polling platform-service flags', async () => { + const fetchMock = vi.fn(async () => new Response(JSON.stringify({ flags: { encryption_enabled: true } }), { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + + await initEncryption('notelett', { info: vi.fn() }, 'platform-req-1'); + + expect(fetchMock).toHaveBeenCalledOnce(); + const init = fetchMock.mock.calls[0]?.[1] as RequestInit; + expect(init.headers).toMatchObject({ + 'x-product-id': 'notelett', + 'x-request-id': 'platform-req-1', + }); + }); +}); diff --git a/backend/src/lib/field-encrypt.ts b/backend/src/lib/field-encrypt.ts index 349d5d8..8a1a14c 100644 --- a/backend/src/lib/field-encrypt.ts +++ b/backend/src/lib/field-encrypt.ts @@ -8,18 +8,19 @@ import { createFieldEncryptor, type FieldEncryptor } from '@bytelyst/field-encrypt'; import { config } from './config.js'; +import { requestIdHeaders } from './request-context.js'; let _encryptor: FieldEncryptor | null = null; let _enabled: boolean = config.FIELD_ENCRYPT_ENABLED; /** Poll encryption_enabled flag from platform-service at startup. */ -export async function initEncryption(productId: string, logger?: { info: (msg: string) => void }): Promise { +export async function initEncryption(productId: string, logger?: { info: (msg: string) => void }, requestId?: string): Promise { const log = logger ?? { info: () => {} }; try { const url = `${config.PLATFORM_SERVICE_URL}/flags/poll?platform=backend`; const res = await fetch(url, { signal: AbortSignal.timeout(3000), - headers: { 'x-product-id': productId }, + headers: { 'x-product-id': productId, ...requestIdHeaders(requestId) }, }); if (res.ok) { const data = (await res.json()) as { flags: Record }; diff --git a/backend/src/lib/request-context.test.ts b/backend/src/lib/request-context.test.ts new file mode 100644 index 0000000..daa09d7 --- /dev/null +++ b/backend/src/lib/request-context.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import type { FastifyRequest } from 'fastify'; +import { getRequestId, requestIdHeaders } from './request-context.js'; + +function makeReq(headers: FastifyRequest['headers'], id = 'generated-id'): FastifyRequest { + return { headers, id } as FastifyRequest; +} + +describe('request id propagation helpers', () => { + it('prefers inbound x-request-id over generated request id', () => { + expect(getRequestId(makeReq({ 'x-request-id': 'req-inbound' }))).toBe('req-inbound'); + }); + + it('falls back to the Fastify request id', () => { + expect(getRequestId(makeReq({}))).toBe('generated-id'); + }); + + it('builds outbound request-id headers only when a value exists', () => { + expect(requestIdHeaders('req-123')).toEqual({ 'x-request-id': 'req-123' }); + expect(requestIdHeaders(undefined)).toEqual({}); + }); +}); diff --git a/backend/src/lib/request-context.ts b/backend/src/lib/request-context.ts index 73d46be..308aeff 100644 --- a/backend/src/lib/request-context.ts +++ b/backend/src/lib/request-context.ts @@ -3,6 +3,7 @@ */ import { createRequestContext } from '@bytelyst/fastify-auth'; import type { FastifyRequest } from 'fastify'; +import { randomUUID } from 'node:crypto'; import { PRODUCT_ID } from './product-config.js'; export type { JwtPayload } from '@bytelyst/fastify-auth'; @@ -18,3 +19,24 @@ export function getUserId(req: FastifyRequest): string { // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any return _ctx.getUserId(req as any); } + +type RequestIdCarrier = { + headers: Record; + id: string; +}; + +export function getRequestId(req: RequestIdCarrier): string { + const header = req.headers['x-request-id']; + if (Array.isArray(header)) { + return header[0] ?? req.id; + } + return header ?? req.id; +} + +export function createOutboundRequestId(): string { + return randomUUID(); +} + +export function requestIdHeaders(requestId: string | undefined): Record { + return requestId ? { 'x-request-id': requestId } : {}; +} diff --git a/backend/src/modules/notes/routes.ts b/backend/src/modules/notes/routes.ts index 292265e..60e9800 100644 --- a/backend/src/modules/notes/routes.ts +++ b/backend/src/modules/notes/routes.ts @@ -10,6 +10,7 @@ import { extractFromText } from '../../lib/extraction-client.js'; import { rankNotesByQuery } from '../../lib/note-search-rank.js'; import { runCopilotTransform, suggestTitleFromBody } from '../../lib/copilot-transform.js'; import { assertRateLimit, rateLimitKey } from '../../lib/rate-limit.js'; +import { getRequestId } from '../../lib/request-context.js'; import * as repo from './repository.js'; import * as artifactRepo from '../note-artifacts/repository.js'; import * as shareRepo from '../note-shares/repository.js'; @@ -379,7 +380,7 @@ export async function noteRoutes(app: RouteApp) { let summaryText: string; try { - const result = await extractFromText(existing.body, 'summarization'); + const result = await extractFromText(existing.body, 'summarization', { requestId: getRequestId(req) }); summaryText = result.summary ?? 'No summary generated.'; } catch { summaryText = `Auto-summary of: ${existing.title}. ${existing.body.slice(0, 200)}...`; diff --git a/backend/src/server.test.ts b/backend/src/server.test.ts index a1fa761..0dfa822 100644 --- a/backend/src/server.test.ts +++ b/backend/src/server.test.ts @@ -66,7 +66,11 @@ vi.mock('./lib/product-config.js', () => ({ productConfig: { productId: 'notelett', displayName: 'NoteLett' }, })); vi.mock('./lib/field-encrypt.js', () => ({ initEncryption: vi.fn(async () => undefined), getEncryptor: vi.fn() })); -vi.mock('./lib/request-context.js', () => ({ getUserId: vi.fn(), getRequestProductId: vi.fn() })); +vi.mock('./lib/request-context.js', () => ({ + createOutboundRequestId: vi.fn(() => 'startup-request-id'), + getUserId: vi.fn(), + getRequestProductId: vi.fn(), +})); vi.mock('./lib/feature-flags.js', () => ({ getAllFlags: vi.fn(() => ({})) })); vi.mock('./lib/telemetry.js', () => ({ getBufferedEvents: vi.fn(() => []), flushEvents: vi.fn(() => []) })); vi.mock('./lib/diagnostics-routes.js', () => ({ diagnosticsRoutes: diagnosticsRoutesMock })); diff --git a/backend/src/server.ts b/backend/src/server.ts index dc3458a..f3a5959 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -23,7 +23,7 @@ import { config } from './lib/config.js'; import { DISPLAY_NAME, PRODUCT_ID, productConfig } from './lib/product-config.js'; import { diagnosticsRoutes } from './lib/diagnostics-routes.js'; import { assertRateLimit, rateLimitKey } from './lib/rate-limit.js'; -import type { JwtPayload } from './lib/request-context.js'; +import { createOutboundRequestId, type JwtPayload } from './lib/request-context.js'; import { findShareByToken } from './modules/note-shares/repository.js'; import * as noteRepo from './modules/notes/repository.js'; @@ -120,6 +120,6 @@ app.get('/api/bootstrap', async () => ({ // ── Diagnostics routes (dev/test open, production admin/owner gated) ─────── await diagnosticsRoutes(app); -await initEncryption(PRODUCT_ID, app.log); +await initEncryption(PRODUCT_ID, app.log, createOutboundRequestId()); await startService(app, { port: config.PORT, host: config.HOST });