@theoptimalpartner/nestjs-optiaudit
v0.2.0
Published
NestJS module for OptiAudit event tracking
Readme
@theoptimalpartner/nestjs-optiaudit
NestJS module for OptiAudit event tracking. Zero-config integration with automatic request tracking.
Features
- Zero-config: Reads
OPTIAUDIT_URLandOPTIAUDIT_WRITE_KEYfrom environment - Decorators:
@TrackEvent()and@TrackPageView()for declarative tracking - Auto context extraction: Extracts
actor_idfromreq.contextorreq.user - Silent fallback: Disabled automatically in tests or when env vars are missing
- Request ID propagation: Middleware for
X-Request-IDheader
Installation
npm install @theoptimalpartner/nestjs-optiauditQuick Start
1. Environment Variables
OPTIAUDIT_URL=https://some-url.optiaudit.com/api/v1
OPTIAUDIT_WRITE_KEY=your_write_key2. Import Module
// app.module.ts
import { Module } from '@nestjs/common';
import { OptiauditModule } from '@theoptimalpartner/nestjs-optiaudit';
@Module({
imports: [
OptiauditModule.forRoot(), // That's it!
],
})
export class AppModule {}3. Use Decorators
import { Controller, Get, Post } from '@nestjs/common';
import { TrackEvent, TrackPageView } from '@theoptimalpartner/nestjs-optiaudit';
@Controller('documents')
export class DocumentsController {
@Get()
@TrackPageView('documents')
findAll() {
return this.documentsService.findAll();
}
@Post()
@TrackEvent('create', 'documents')
create(@Body() dto: CreateDocumentDto) {
return this.documentsService.create(dto);
}
@Post(':id/approve')
@TrackEvent('update', 'documents', { action: 'document_approved' })
approve(@Param('id') id: string) {
return this.documentsService.approve(id);
}
@Delete(':id')
@TrackEvent('delete', 'documents')
remove(@Param('id') id: string) {
return this.documentsService.remove(id);
}
}Event types. event_type must be one of the nine types in OptiAudit's catalog: page_view, login, login_failed, logout, create, update, delete, export, config_change. Any other value is rejected by OptiAudit with a 422 and, since tracking is fire-and-forget, the event is lost without a visible error.
Action. To tell apart specific actions that share a type (approve vs. reject, both update), pass { action } as the third argument. It is sent as metadata.action, and OptiAudit can filter and rank by it (GET /events?action=…, top_actions in /stats).
Manual Tracking
For custom events outside of HTTP requests:
import { Injectable } from '@nestjs/common';
import { OptiauditService } from '@theoptimalpartner/nestjs-optiaudit';
@Injectable()
export class PaymentsService {
constructor(private readonly optiaudit: OptiauditService) {}
async processPayment(userId: string, amount: number) {
// ... process payment ...
await this.optiaudit.track({
event_type: 'create',
actor_id: userId,
metadata: { module: 'payments', action: 'payment_processed', amount, currency: 'USD' },
});
}
}Context Extraction
The interceptor automatically extracts user info from the request:
| Priority | Source | Fields |
|----------|--------|--------|
| 1 | req.context | user_id, actor_type, organization_id, email, username, sub |
| 2 | req.user | user_id or id, actor_type, organization_id, email, user_email, username, sub |
If neither has a user id, nothing is tracked (no 'anonymous' fallback since 0.1.7).
Extracted fields:
user_id/id→actor_id(required)actor_type→actor_type(optional, see below)organization_id→organization_id(optional)email/user_email→actor_email(optional)username→metadata.username(optional, for Cognito login with username)sub→metadata.sub(optional, for Cognito integration)
Actor type. OptiAudit counts only people (user) in its usage stats by default. When a request is
authenticated with a provider's own credential (an API token, not a user session), the guard that validates it
must set the actor type explicitly; it is never inferred from the actor_id:
req.context = {
user_id: `integration:org-${orgId}:tp-${providerId}`,
actor_type: 'integration', // 'user' (default when omitted) | 'integration' | 'system'
organization_id: orgId,
};The value is normalized (case and surrounding spaces are ignored). An unknown value is sent as 'user', with a
warning and the original in metadata.invalid_actor_type: OptiAudit would reject the whole event otherwise.
This works with:
- Custom
x-encrypted-contextmiddleware (TheOptimal backends) - Passport.js / JWT strategies (
req.user) - AWS Cognito (
subfield) - Any middleware that sets
req.contextorreq.user
Note: The context extraction logic may need updates as new projects adopt this library. If your project uses a different structure (e.g.,
req.session.userId), please open a PR to add support or use manual tracking instead.
Client IP Resolution
The interceptor resolves the real client IP automatically (since v0.1.8).
Behind an ALB / ingress / reverse proxy, req.ip (the socket address) is the
internal hop (e.g. 10.x.x.x), not the user. The interceptor reads
X-Forwarded-For and returns the real public client:
- Anti-spoofing: the chain is walked right-to-left, returning the first public address. A trusted proxy appends the true socket IP to the right, so values a client injects on the left are ignored.
- Normalization: IPv4-mapped IPv6 (
::ffff:1.2.3.4) is reduced to1.2.3.4and::1to127.0.0.1, so values persist as clean IPv4 in OptiAudit'sINETcolumn. - Fallback: if there is no
X-Forwarded-For, it usesreq.ip/socket.remoteAddress(normalized). If the whole chain is private (internal-only traffic), the leftmost entry is kept as best effort.
Recommended (defense in depth): also enable
trust proxyin your app so Express itself computesreq.ipcorrectly. Scope it to your internal subnets to avoid header spoofing:// main.ts app.set('trust proxy', ['loopback', '10.0.0.0/8', '172.16.0.0/12']);
Compatibility
| Dependency | Versions |
|------------|----------|
| @nestjs/common | ^10.0.0, ^11.0.0 |
| @nestjs/core | ^10.0.0, ^11.0.0 |
| @nestjs/axios | ^3.0.0, ^4.0.0 |
| rxjs | ^7.0.0 |
| Node.js | >= 18 |
Note: If your project uses a different version of these dependencies, please open a PR to add compatibility or report an issue.
Advanced Configuration
OptiauditModule.forRoot({
url: 'https://custom-url.com/api/v1', // Override env var
writeKey: 'custom-key', // Override env var
timeout: 5000, // Request timeout (default: 3000ms)
enabled: false, // Force disable
logErrors: false, // Disable warning logs on failure
})Request ID Middleware
Propagates X-Request-ID header for request correlation:
// main.ts
import { RequestIdMiddleware } from '@theoptimalpartner/nestjs-optiaudit';
// Apply globally
app.use(new RequestIdMiddleware().use);Or in a module:
import { MiddlewareConsumer, Module } from '@nestjs/common';
import { RequestIdMiddleware } from '@theoptimalpartner/nestjs-optiaudit';
@Module({})
export class AppModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(RequestIdMiddleware).forRoutes('*');
}
}Track Params
Full interface for optiaudit.track():
interface TrackParams {
event_type: string; // Required: event name
actor_id: string; // Required: user/actor ID
actor_type?: 'user' | 'integration' | 'system'; // Optional: OptiAudit assumes 'user'
actor_email?: string; // Optional: actor email
organization_id?: number; // Optional: org ID
resource_path?: string; // Optional: endpoint path
ip_address?: string; // Optional: client IP
user_agent?: string; // Optional: browser/client
session_id?: string; // Optional: session ID
metadata?: Record<string, unknown>; // Optional: custom data
}Behavior
| Condition | Behavior |
|-----------|----------|
| No env vars | Silently disabled |
| NODE_ENV=test | Silently disabled |
| HTTP error | Logs warning, doesn't throw |
| Timeout (3s) | Logs warning, doesn't throw |
Contributing
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add some amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
License
MIT
