Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import { AdminModule } from './modules/admin/admin.module';
import { RequestMetricsMiddleware } from './modules/metrics/metrics.middleware';
import { DeadLetterModule } from './modules/dead-letter/dead-letter.module';
import { AgentTraceInterceptor } from './common/interceptors/agent-trace.interceptor';
import { RequestContextInterceptor } from './common/interceptors/request-context.interceptor';
import { AuditLogInterceptor } from './common/interceptors/audit-log.interceptor';

/**
Expand Down Expand Up @@ -131,6 +132,7 @@ import { AuditLogInterceptor } from './common/interceptors/audit-log.interceptor
{ provide: APP_GUARD, useClass: RolesGuard },
{ provide: APP_GUARD, useClass: ScopesGuard },
{ provide: APP_GUARD, useClass: AstroidThrottlerGuard },
{ provide: APP_INTERCEPTOR, useClass: RequestContextInterceptor },
{ provide: APP_INTERCEPTOR, useClass: AgentTraceInterceptor },
{ provide: APP_INTERCEPTOR, useClass: AuditLogInterceptor },
{ provide: APP_INTERCEPTOR, useClass: ResponseInterceptor },
Expand Down
117 changes: 117 additions & 0 deletions src/common/context/request-context.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { describe, it, expect } from 'vitest';
import { RequestContext, RequestContextData } from './request-context';

describe('RequestContext', () => {
const baseContext = (): RequestContextData => ({
identity: {
requestId: 'req_123',
correlationId: 'corr_123',
traceId: 'trace_123',
method: 'POST',
path: '/api/v1/agents/agent-1/execute',
url: '/api/v1/agents/agent-1/execute',
ip: '127.0.0.1',
userAgent: 'test-agent',
startedAt: 1000,
},
principal: { userId: 'user-1', organizationId: 'org-1', agentId: 'agent-1' },
timings: {},
data: {},
});

it('should expose the store within a run() boundary and nothing outside', () => {
RequestContext.run(baseContext(), () => {
expect(RequestContext.getStore()).toEqual(baseContext());
expect(RequestContext.get()).toEqual(baseContext());
});

expect(RequestContext.getStore()).toBeUndefined();
});

it('should propagate the context across async continuations', async () => {
let captured: RequestContextData | undefined;

await new Promise<void>((resolve) => {
RequestContext.run(baseContext(), () => {
setTimeout(() => {
captured = RequestContext.getStore();
resolve();
}, 5);
});
});

expect(captured?.identity.requestId).toBe('req_123');
});

it('should expose typed identity getters', () => {
RequestContext.run(baseContext(), () => {
expect(RequestContext.getRequestId()).toBe('req_123');
expect(RequestContext.getCorrelationId()).toBe('corr_123');
expect(RequestContext.getTraceId()).toBe('trace_123');
expect(RequestContext.getMethod()).toBe('POST');
expect(RequestContext.getPath()).toBe('/api/v1/agents/agent-1/execute');
expect(RequestContext.getStartedAt()).toBe(1000);
});
});

it('should expose typed principal getters', () => {
RequestContext.run(baseContext(), () => {
expect(RequestContext.getPrincipal()).toEqual({
userId: 'user-1',
organizationId: 'org-1',
agentId: 'agent-1',
});
expect(RequestContext.getUserId()).toBe('user-1');
expect(RequestContext.getOrganizationId()).toBe('org-1');
expect(RequestContext.getAgentId()).toBe('agent-1');
});
});

it('should return undefined getters when no context is active', () => {
expect(RequestContext.getRequestId()).toBeUndefined();
expect(RequestContext.getUserId()).toBeUndefined();
expect(RequestContext.getOrganizationId()).toBeUndefined();
expect(RequestContext.getAgentId()).toBeUndefined();
});

it('should merge a partial principal via setPrincipal', () => {
RequestContext.run(baseContext(), () => {
RequestContext.setPrincipal({ role: 'ADMIN' as const, authMethod: 'jwt' });
expect(RequestContext.getPrincipal()).toEqual({
userId: 'user-1',
organizationId: 'org-1',
agentId: 'agent-1',
role: 'ADMIN',
authMethod: 'jwt',
});
});
});

it('should store and read arbitrary request-scoped data', () => {
RequestContext.run(baseContext(), () => {
expect(RequestContext.getData('cart')).toBeUndefined();
RequestContext.setData('cart', { count: 3 });
expect(RequestContext.getData('cart')).toEqual({ count: 3 });
});
});

it('should record and read timings measured from request start', () => {
RequestContext.run(baseContext(), () => {
RequestContext.markTiming('db');
const timing = RequestContext.getTiming('db');
expect(typeof timing).toBe('number');
expect(timing!).toBeGreaterThanOrEqual(0);
});
});

it('should not throw when mutating with no active context', () => {
expect(() => {
RequestContext.setPrincipal({ userId: 'x' });
RequestContext.setData('k', 'v');
RequestContext.markTiming('nope');
}).not.toThrow();

expect(RequestContext.getData('k')).toBeUndefined();
expect(RequestContext.getTiming('nope')).toBeUndefined();
});
});
163 changes: 163 additions & 0 deletions src/common/context/request-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { AsyncLocalStorage } from 'async_hooks';

/**
* Structured principal attached to a request once authentication resolves.
* Fields are optional so the same shape works for public, API-key and webhook
* traffic as well as fully authenticated JWT requests.
*/
export interface RequestPrincipal {
userId?: string;
organizationId?: string;
agentId?: string;
role?: string;
authMethod?: 'jwt' | 'api-key' | 'webhook' | 'service' | 'public';
}

/**
* Immutable request-level identifiers and routing metadata captured when the
* request enters the process. Never mutated after seeding.
*/
export interface RequestIdentity {
requestId: string;
correlationId: string;
traceId: string;
method: string;
path: string;
url: string;
ip: string | null;
userAgent: string | null;
startedAt: number;
}

/**
* The full, structured object stored in AsyncLocalStorage for the lifetime of a
* single request. Grouped so consumers can read identity, routing metadata and
* arbitrary request-scoped values without reaching into raw headers.
*/
export interface RequestContextData {
identity: RequestIdentity;
principal?: RequestPrincipal;
timings: Record<string, number>;
data: Record<string, unknown>;
}

/**
* Structured request context storage built on `AsyncLocalStorage`.
*
* A single store instance is entered once per HTTP request (see
* `RequestContextInterceptor`) so that any code running in the request's async
* flow — controllers, services, guards, filters, queue producers — can read a
* consistent, typed snapshot of the request without threading arguments
* through the call tree.
*
* The subtle-but-powerful property of `AsyncLocalStorage` is that the context
* is inherited by every async continuation spawned inside `run()`, which makes
* it the correct primitive for tracing and request-scoped state in Node.
*/
export class RequestContext {
private static readonly storage = new AsyncLocalStorage<RequestContextData>();

/**
* Enters a request context for the duration of `fn`, propagating it through
* every asynchronous operation spawned inside.
*/
static run<R>(data: RequestContextData, fn: () => R): R {
return this.storage.run(data, fn);
}

/** Returns the raw structured store bound to the current async flow. */
static getStore(): RequestContextData | undefined {
return this.storage.getStore();
}

/** Convenience alias for {@link getStore}. */
static get(): RequestContextData | undefined {
return this.getStore();
}

static getRequestId(): string | undefined {
return this.storage.getStore()?.identity.requestId;
}

static getCorrelationId(): string | undefined {
return this.storage.getStore()?.identity.correlationId;
}

static getTraceId(): string | undefined {
return this.storage.getStore()?.identity.traceId;
}

static getMethod(): string | undefined {
return this.storage.getStore()?.identity.method;
}

static getPath(): string | undefined {
return this.storage.getStore()?.identity.path;
}

static getStartedAt(): number | undefined {
return this.storage.getStore()?.identity.startedAt;
}

static getPrincipal(): RequestPrincipal | undefined {
return this.storage.getStore()?.principal;
}

static getUserId(): string | undefined {
return this.storage.getStore()?.principal?.userId;
}

static getOrganizationId(): string | undefined {
return this.storage.getStore()?.principal?.organizationId;
}

static getAgentId(): string | undefined {
return this.storage.getStore()?.principal?.agentId;
}

/**
* Merges a partial principal into the current store. No-op when no context
* is active so code that runs outside a request never throws.
*/
static setPrincipal(principal: RequestPrincipal): void {
const store = this.storage.getStore();
if (store) {
store.principal = { ...store.principal, ...principal };
}
}

/**
* Reads a request-scoped value previously stored with {@link setData}.
*/
static getData<T = unknown>(key: string): T | undefined {
return this.storage.getStore()?.data[key] as T | undefined;
}

/**
* Stores an arbitrary request-scoped value keyed by `name`.
*/
static setData(name: string, value: unknown): void {
const store = this.storage.getStore();
if (store) {
store.data[name] = value;
}
}

/**
* Records the elapsed time (ms) since the request started under `name`,
* then stores it in the context `timings` map for observability.
*/
static markTiming(name: string): void {
const store = this.storage.getStore();
if (store) {
store.timings[name] = Date.now() - store.identity.startedAt;
}
}

/**
* Reads a recorded timing value (ms) by name, if present.
*/
static getTiming(name: string): number | undefined {
return this.storage.getStore()?.timings[name];
}
}
Loading
Loading