fix(backend): propagate outbound request ids
This commit is contained in:
parent
45b03c482a
commit
c31f51ddbd
22
backend/src/lib/extraction-client.test.ts
Normal file
22
backend/src/lib/extraction-client.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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 }),
|
||||
});
|
||||
|
||||
|
||||
23
backend/src/lib/field-encrypt.test.ts
Normal file
23
backend/src/lib/field-encrypt.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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> };
|
||||
|
||||
22
backend/src/lib/request-context.test.ts
Normal file
22
backend/src/lib/request-context.test.ts
Normal 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({});
|
||||
});
|
||||
});
|
||||
@ -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 } : {};
|
||||
}
|
||||
|
||||
@ -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)}...`;
|
||||
|
||||
@ -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 }));
|
||||
|
||||
@ -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 });
|
||||
|
||||
Loading…
Reference in New Issue
Block a user