-
Notifications
You must be signed in to change notification settings - Fork 52
feat(utils): add request context hash helper for trace correlation #237
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| // src/utils/request-context-hash.utils.ts | ||
| // Produces a short deterministic hash of safe (non-sensitive) request context | ||
| // fields so log entries from the same logical operation share a stable tag. | ||
| import crypto from 'crypto'; | ||
| import { Request } from 'express'; | ||
|
|
||
| /** | ||
| * Compute a short SHA-256 hex digest from safe request context fields. | ||
| * | ||
| * Fields included (non-sensitive, stable per endpoint): | ||
| * - HTTP method | ||
| * - URL path without query string | ||
| * - `content-type` request header (if present) | ||
| * | ||
| * Fields deliberately excluded: | ||
| * - Authorization / Cookie headers | ||
| * - Request body | ||
| * - Query string (contains user-supplied values) | ||
| * - Full URL (includes query string) | ||
| * | ||
| * The digest is truncated to 12 hex characters (48 bits) — enough for | ||
| * trace correlation without bloating log lines. | ||
| * | ||
| * @param req - Express Request object | ||
| * @returns 12-character lowercase hex string | ||
| * | ||
| * @example | ||
| * // GET /api/creators?page=1 | ||
| * computeRequestContextHash(req) // e.g. "3a9f1c2b4e7d" | ||
| */ | ||
| export function computeRequestContextHash(req: Request): string { | ||
| const path = (req.path || '/').split('?')[0]; | ||
| const contentType = (req.headers['content-type'] ?? '').split(';')[0].trim(); | ||
|
|
||
| const payload = [req.method.toUpperCase(), path, contentType].join('\x00'); | ||
|
|
||
| return crypto.createHash('sha256').update(payload).digest('hex').slice(0, 12); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,105 @@ | ||
| import { Request } from 'express'; | ||
| import { computeRequestContextHash } from '../request-context-hash.utils'; | ||
|
|
||
| function makeReq(overrides: Partial<{ method: string; path: string; headers: Record<string, string> }>): Request { | ||
| return { | ||
| method: 'GET', | ||
| path: '/', | ||
| headers: {}, | ||
| ...overrides, | ||
| } as unknown as Request; | ||
|
Comment on lines
+4
to
+10
|
||
| } | ||
|
|
||
| describe('computeRequestContextHash()', () => { | ||
| // ── Output format ────────────────────────────────────────────────────────── | ||
|
|
||
| it('returns a 12-character hex string', () => { | ||
| const hash = computeRequestContextHash(makeReq({})); | ||
| expect(hash).toMatch(/^[0-9a-f]{12}$/); | ||
| }); | ||
|
|
||
| // ── Determinism ──────────────────────────────────────────────────────────── | ||
|
|
||
| it('returns the same hash for identical inputs', () => { | ||
| const req = makeReq({ method: 'GET', path: '/api/creators' }); | ||
| expect(computeRequestContextHash(req)).toBe(computeRequestContextHash(req)); | ||
| }); | ||
|
|
||
| it('produces a stable hash across separate calls with matching fields', () => { | ||
| const a = makeReq({ method: 'POST', path: '/api/auth/login', headers: { 'content-type': 'application/json' } }); | ||
| const b = makeReq({ method: 'POST', path: '/api/auth/login', headers: { 'content-type': 'application/json' } }); | ||
| expect(computeRequestContextHash(a)).toBe(computeRequestContextHash(b)); | ||
| }); | ||
|
|
||
| // ── Sensitivity to field values ──────────────────────────────────────────── | ||
|
|
||
| it('produces different hashes for different HTTP methods', () => { | ||
| const get = makeReq({ method: 'GET', path: '/api/creators' }); | ||
| const post = makeReq({ method: 'POST', path: '/api/creators' }); | ||
| expect(computeRequestContextHash(get)).not.toBe(computeRequestContextHash(post)); | ||
| }); | ||
|
|
||
| it('produces different hashes for different paths', () => { | ||
| const a = makeReq({ path: '/api/creators' }); | ||
| const b = makeReq({ path: '/api/wallet' }); | ||
| expect(computeRequestContextHash(a)).not.toBe(computeRequestContextHash(b)); | ||
| }); | ||
|
|
||
| it('produces different hashes for different content-type headers', () => { | ||
| const json = makeReq({ method: 'POST', path: '/upload', headers: { 'content-type': 'application/json' } }); | ||
| const form = makeReq({ method: 'POST', path: '/upload', headers: { 'content-type': 'multipart/form-data' } }); | ||
| expect(computeRequestContextHash(json)).not.toBe(computeRequestContextHash(form)); | ||
| }); | ||
|
|
||
| // ── Query-string isolation ───────────────────────────────────────────────── | ||
|
|
||
| it('ignores query strings in the path', () => { | ||
| const withQuery = makeReq({ path: '/api/creators?page=1&limit=20' }); | ||
| const withoutQuery = makeReq({ path: '/api/creators' }); | ||
| expect(computeRequestContextHash(withQuery)).toBe(computeRequestContextHash(withoutQuery)); | ||
| }); | ||
|
|
||
| it('ignores different query strings for the same path', () => { | ||
| const a = makeReq({ path: '/api/creators?q=music' }); | ||
| const b = makeReq({ path: '/api/creators?q=sports&page=2' }); | ||
| expect(computeRequestContextHash(a)).toBe(computeRequestContextHash(b)); | ||
| }); | ||
|
|
||
| // ── Sensitive-header isolation ───────────────────────────────────────────── | ||
|
|
||
| it('produces the same hash regardless of Authorization header value', () => { | ||
| const noAuth = makeReq({ path: '/api/creators', headers: {} }); | ||
| const withAuth = makeReq({ path: '/api/creators', headers: { authorization: 'Bearer secret-token' } }); | ||
| expect(computeRequestContextHash(noAuth)).toBe(computeRequestContextHash(withAuth)); | ||
| }); | ||
|
|
||
| it('produces the same hash regardless of Cookie header value', () => { | ||
| const noCookie = makeReq({ path: '/api/creators', headers: {} }); | ||
| const withCookie = makeReq({ path: '/api/creators', headers: { cookie: 'session=abc123' } }); | ||
| expect(computeRequestContextHash(noCookie)).toBe(computeRequestContextHash(withCookie)); | ||
| }); | ||
|
|
||
| // ── Content-type charset suffix stripping ───────────────────────────────── | ||
|
|
||
| it('treats content-type with and without charset as the same', () => { | ||
| const plain = makeReq({ method: 'POST', path: '/api/auth', headers: { 'content-type': 'application/json' } }); | ||
| const withCharset = makeReq({ method: 'POST', path: '/api/auth', headers: { 'content-type': 'application/json; charset=utf-8' } }); | ||
| expect(computeRequestContextHash(plain)).toBe(computeRequestContextHash(withCharset)); | ||
| }); | ||
|
|
||
| // ── Missing / empty path ─────────────────────────────────────────────────── | ||
|
|
||
| it('falls back to "/" when path is empty', () => { | ||
| const emptyPath = makeReq({ path: '' }); | ||
| const rootPath = makeReq({ path: '/' }); | ||
| expect(computeRequestContextHash(emptyPath)).toBe(computeRequestContextHash(rootPath)); | ||
| }); | ||
|
|
||
| // ── Method normalisation ─────────────────────────────────────────────────── | ||
|
|
||
| it('is case-insensitive for method (get vs GET)', () => { | ||
| const upper = makeReq({ method: 'GET', path: '/api/creators' }); | ||
| const lower = makeReq({ method: 'get', path: '/api/creators' }); | ||
| expect(computeRequestContextHash(upper)).toBe(computeRequestContextHash(lower)); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
req.headers['content-type']is typed asstring | string[] | undefined(IncomingHttpHeaders). Calling.split()on it will either fail TypeScript compilation or throw at runtime if multiplecontent-typeheaders are present. Normalize to a single string first (e.g.,Array.isArray(v) ? v[0] : v ?? '') before splitting/trim.