fix(backend): propagate outbound request ids

This commit is contained in:
Saravana Achu Mac 2026-05-05 11:06:02 -07:00
parent 45b03c482a
commit c31f51ddbd
9 changed files with 104 additions and 7 deletions

View File

@ -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',
});
});
});

View File

@ -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<ExtractionResult> {
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 }),
});

View File

@ -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',
});
});
});

View File

@ -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<void> {
export async function initEncryption(productId: string, logger?: { info: (msg: string) => void }, requestId?: string): Promise<void> {
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<string, boolean> };

View File

@ -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({});
});
});

View File

@ -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<string, string | string[] | undefined>;
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<string, string> {
return requestId ? { 'x-request-id': requestId } : {};
}

View File

@ -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)}...`;

View File

@ -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 }));

View File

@ -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 });