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
20 changes: 18 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,24 @@ jobs:
- name: Lint
run: pnpm lint

- name: Test
run: pnpm test
- name: Test (server coverage)
run: pnpm --filter api test:cov

- name: Test (client coverage)
run: pnpm --filter web test:cov

- name: Build
run: pnpm build

- name: Upload Test Artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: ci-test-artifacts-${{ github.run_id }}
path: |
apps/api/coverage
apps/api/test-results
apps/web/coverage
apps/web/test-results
if-no-files-found: warn
retention-days: 14
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ node_modules
.next
dist
coverage
test-results
.env
.env.local
.DS_Store
Expand Down
11 changes: 11 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,17 @@ pnpm --filter api test
pnpm --filter web test
```

### Running coverage locally

```bash
pnpm --filter api test:cov
pnpm --filter web test:cov
```

Coverage thresholds are enforced in CI for both suites. See
[`docs/testing/coverage-policy.md`](./docs/testing/coverage-policy.md)
for thresholds, artifact details, and rollout policy.

## Database and Prisma Rules

When changing `apps/api/prisma/schema.prisma`:
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,12 @@ Package-level examples:

- `pnpm --filter api test`
- `pnpm --filter web test`
- `pnpm --filter api test:cov`
- `pnpm --filter web test:cov`

Coverage policy and CI quality gate details:

- [`docs/testing/coverage-policy.md`](./docs/testing/coverage-policy.md)

## API Route Overview

Expand Down
31 changes: 31 additions & 0 deletions apps/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,37 @@ API default URL: `http://localhost:4000`
- Tasks: `/projects/:projectId/tasks/...`
- Project chat: `/projects/:projectId/chat/messages`

## Observability

- Every request is assigned an `x-request-id`:
- if client sends a valid `x-request-id`, server reuses it
- otherwise server generates one and returns it in response headers
- Server emits structured JSON logs for:
- all requests (`http.request`)
- all write mutations (`http.mutation`)
- request failures (`http.error`)
- Error logs include request ID, route/method, actor context (when authenticated), and route params.

### Local Debugging

Run API and filter logs by request ID:

```bash
pnpm --filter api dev
```

In another terminal:

```bash
curl -i -H "x-request-id: local-debug-12345678" http://localhost:4000/teams
```

Then search logs:

```bash
pnpm --filter api dev | rg "local-debug-12345678"
```

## Troubleshooting

### Prisma auth/database errors
Expand Down
13 changes: 11 additions & 2 deletions apps/api/jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,20 @@ const config: Config = {
rootDir: ".",
testRegex: ".*\\.spec\\.ts$",
transform: {
"^.+\\.(t|j)sx?$": "ts-jest"
"^.+\\.(t|j)sx?$": "ts-jest",
},
collectCoverageFrom: ["src/**/*.(t|j)s"],
coverageDirectory: "./coverage",
testEnvironment: "node"
coverageReporters: ["text-summary", "json-summary", "lcov"],
coverageThreshold: {
global: {
statements: 70,
branches: 55,
functions: 50,
lines: 70,
},
},
testEnvironment: "node",
};

export default config;
2 changes: 1 addition & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"prisma:seed": "prisma db seed",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage"
"test:cov": "mkdir -p test-results && jest --coverage --ci --json --outputFile=./test-results/jest-results.json"
},
"dependencies": {
"@nestjs/common": "^10.4.5",
Expand Down
18 changes: 17 additions & 1 deletion apps/api/src/app.module.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { APP_GUARD } from "@nestjs/core";
import { APP_GUARD, APP_INTERCEPTOR } from "@nestjs/core";
import { MODULE_METADATA } from "@nestjs/common/constants";

import { RolesGuard } from "./auth/guards/roles.guard";
import { JwtAuthGuard } from "./auth/guards/jwt-auth.guard";
import { HttpObservabilityInterceptor } from "./observability/interceptors/http-observability.interceptor";
import { AppModule } from "./app.module";
import { AppThrottlerGuard } from "./security/guards/app-throttler.guard";

Expand All @@ -21,3 +22,18 @@ describe("AppModule guard registration", () => {
expect(appGuards).not.toContain(RolesGuard);
});
});

describe("AppModule interceptor registration", () => {
it("registers HttpObservabilityInterceptor as APP_INTERCEPTOR", () => {
const providers = (Reflect.getMetadata(MODULE_METADATA.PROVIDERS, AppModule) ?? []) as Array<{
provide?: unknown;
useClass?: unknown;
}>;

const appInterceptors = providers
.filter((provider) => provider.provide === APP_INTERCEPTOR)
.map((provider) => provider.useClass);

expect(appInterceptors).toEqual([HttpObservabilityInterceptor]);
});
});
16 changes: 13 additions & 3 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { Module } from "@nestjs/common";
import { APP_GUARD } from "@nestjs/core";
import { MiddlewareConsumer, Module, NestModule } from "@nestjs/common";
import { APP_GUARD, APP_INTERCEPTOR } from "@nestjs/core";
import { ThrottlerModule } from "@nestjs/throttler";

import { AuthModule } from "./auth/auth.module";
import { JwtAuthGuard } from "./auth/guards/jwt-auth.guard";
import { MailModule } from "./mail/mail.module";
import { HttpObservabilityInterceptor } from "./observability/interceptors/http-observability.interceptor";
import { RequestIdMiddleware } from "./observability/middleware/request-id.middleware";
import { PrismaModule } from "./prisma/prisma.module";
import { ProjectChatModule } from "./project-chat/project-chat.module";
import { ProjectsModule } from "./projects/projects.module";
Expand Down Expand Up @@ -35,6 +37,14 @@ import { UsersModule } from "./users/users.module";
provide: APP_GUARD,
useClass: AppThrottlerGuard,
},
{
provide: APP_INTERCEPTOR,
useClass: HttpObservabilityInterceptor,
},
],
})
export class AppModule {}
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {
consumer.apply(RequestIdMiddleware).forRoutes("*");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import {
HttpException,
HttpStatus,
Logger,
type CallHandler,
type ExecutionContext,
} from "@nestjs/common";
import { lastValueFrom, of, throwError } from "rxjs";

import { HttpObservabilityInterceptor } from "./http-observability.interceptor";

function createHttpExecutionContext(
request: Record<string, unknown>,
response: Record<string, unknown>,
): ExecutionContext {
return {
getType: () => "http",
switchToHttp: () => ({
getRequest: () => request,
getResponse: () => response,
}),
} as ExecutionContext;
}

describe("HttpObservabilityInterceptor", () => {
beforeEach(() => {
jest.restoreAllMocks();
});

it("emits structured mutation log with request and actor context", async () => {
const logSpy = jest.spyOn(Logger.prototype, "log").mockImplementation();
const interceptor = new HttpObservabilityInterceptor();
const request = {
requestId: "req-12345678",
method: "POST",
originalUrl: "/projects/proj_1/tasks",
params: { projectId: "proj_1" },
user: { sub: "user_1", email: "member@teamflow.dev" },
};
const response = { statusCode: 201 };
const context = createHttpExecutionContext(request, response);
const next: CallHandler = {
handle: () => of({ ok: true }),
};

await lastValueFrom(interceptor.intercept(context, next));

expect(logSpy).toHaveBeenCalledTimes(1);
const payload = JSON.parse(logSpy.mock.calls[0]?.[0] as string) as Record<string, unknown>;

expect(payload.event).toBe("http.mutation");
expect(payload.requestId).toBe("req-12345678");
expect(payload.method).toBe("POST");
expect(payload.path).toBe("/projects/proj_1/tasks");
expect(payload.statusCode).toBe(201);
expect(payload.actorId).toBe("user_1");
expect(payload.actorEmail).toBe("member@teamflow.dev");
expect(payload.routeParams).toEqual({ projectId: "proj_1" });
});

it("emits structured error log with status and request context", async () => {
const errorSpy = jest.spyOn(Logger.prototype, "error").mockImplementation();
const interceptor = new HttpObservabilityInterceptor();
const request = {
requestId: "req-err-87654321",
method: "PATCH",
originalUrl: "/projects/proj_1/tasks/task_1",
params: { projectId: "proj_1", id: "task_1" },
user: { sub: "user_1", email: "member@teamflow.dev" },
};
const response = {};
const context = createHttpExecutionContext(request, response);
const next: CallHandler = {
handle: () => throwError(() => new HttpException("Forbidden resource", HttpStatus.FORBIDDEN)),
};

await expect(lastValueFrom(interceptor.intercept(context, next))).rejects.toBeInstanceOf(
HttpException,
);

expect(errorSpy).toHaveBeenCalledTimes(1);
const payload = JSON.parse(errorSpy.mock.calls[0]?.[0] as string) as Record<string, unknown>;

expect(payload.event).toBe("http.error");
expect(payload.requestId).toBe("req-err-87654321");
expect(payload.statusCode).toBe(403);
expect(payload.errorName).toBe("HttpException");
expect(payload.errorMessage).toBe("Forbidden resource");
expect(payload.routeParams).toEqual({ projectId: "proj_1", id: "task_1" });
});
});
Loading
Loading