feat(p9): tenant-scoped /metrics + observability smoke; slice-4 verification green

Task O.4: GET /metrics (session-auth) returns the caller tenant's counters
(interactions by kind/status, outbound by status, AI jobs + cost, tickets,
meetings, inbox, audit-link count) via findScope + Prisma groupBy — no cross-tenant
totals — plus a global relay-lag section. smoke-observability proves the mandated
gate end to end: x-trace-id response header, event correlationId == request trace,
audit link in DB, /metrics counters. Slice-4 verification: 122 tests green, builds,
boundary clean, observability smoke + all prior smokes pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-03 01:14:20 +05:30
parent 6548b40f17
commit e5d3ce5ecd
3 changed files with 111 additions and 1 deletions
+2 -1
View File
@@ -14,6 +14,7 @@ import { AiModule } from './ai/ai.module';
import { CalendarModule } from './calendar/calendar.module';
import { CapabilityModule } from './capability/capability.module';
import { HealthController } from './health.controller';
import { MetricsController } from './observability/metrics.controller';
import { DevController } from './dev/dev.controller';
@Module({
@@ -33,6 +34,6 @@ import { DevController } from './dev/dev.controller';
CalendarModule,
CapabilityModule,
],
controllers: [HealthController, DevController],
controllers: [HealthController, MetricsController, DevController],
})
export class AppModule {}
@@ -0,0 +1,56 @@
import { BadRequestException, Controller, Get, Headers } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
import { SessionVerifier } from '../platform/session.verifier';
/**
* Tenant-scoped metrics (P9 observability, JSON). Returns the CALLER's tenant
* counters (respecting the P9 isolation slice — no cross-tenant totals leak) plus a
* global relay-lag section (non-tenant ops signal). A Prometheus text exposition +
* real dashboards are a follow-up.
*/
@Controller('metrics')
export class MetricsController {
constructor(
private readonly prisma: PrismaService,
private readonly actors: ActorResolver,
private readonly session: SessionVerifier,
) {}
@Get()
async metrics(@Headers('authorization') auth?: string) {
const scope = await this.actors.findScope(this.principal(auth));
const scopeId = scope?.id;
const tenant = scopeId
? {
interactions: await this.prisma.iiosInteraction.groupBy({ by: ['kind', 'status'], where: { scopeId }, _count: true }),
outbound: await this.prisma.iiosOutboundCommand.groupBy({ by: ['status'], where: { scopeId }, _count: true }),
aiJobs: await this.prisma.iiosAiJob.groupBy({ by: ['status'], where: { scopeId }, _count: true }),
aiCostUnits: (await this.prisma.iiosAiModelRun.aggregate({ _sum: { costUnits: true }, where: { scopeId } }))._sum.costUnits ?? 0,
tickets: await this.prisma.iiosTicket.groupBy({ by: ['state'], where: { scopeId }, _count: true }),
meetings: await this.prisma.iiosMeeting.groupBy({ by: ['status'], where: { scopeId }, _count: true }),
inbox: await this.prisma.iiosInboxItem.groupBy({ by: ['state'], where: { scopeId }, _count: true }),
auditLinks: await this.prisma.iiosAuditLink.count({ where: { scopeId } }),
}
: null;
// Global relay lag (ops signal, not tenant data).
const outboxPending = await this.prisma.iiosOutboxEvent.count({ where: { status: { not: 'PUBLISHED' } } });
const oldest = await this.prisma.iiosOutboxEvent.findFirst({ where: { status: { not: 'PUBLISHED' } }, orderBy: { createdAt: 'asc' } });
const oldestPendingMs = oldest ? Date.now() - new Date(oldest.createdAt).getTime() : 0;
return {
ts: new Date().toISOString(),
scope: scope ? { orgId: scope.orgId, appId: scope.appId, tenantId: scope.tenantId, cellId: scope.cellId } : null,
tenant,
relay: { outboxPending, oldestPendingMs },
};
}
private principal(authorization?: string): MessagePrincipal {
const token = (authorization ?? '').replace(/^Bearer\s+/i, '');
if (!token) throw new BadRequestException('Authorization bearer token is required');
return this.session.verify(token);
}
}