Repository: iamolegga/nestjs-pino (ts)

Issue:
[FEATURE REQUEST] Adding Additional Request Scoped Context
Is it possible to add fields to the logged JSON object after the logger is configured?

For example, I'd like to add a userId and organizationId to each log after it's gone through my authorization guard for the duration of the request.

maybe something inside my auth guard like `this.logger.addContext({ userId, orgId});`. Then all logs for that request include the additional context.

I thought about extending the injected PinoLogger class, but don't want to include a RequestScoped injectable essentially everywhere.

Pull request description:
assign
closes #462 

Patch:
diff --git a/README.md b/README.md
index 131f725..3ac5a86 100644
--- a/README.md
+++ b/README.md
@@ -374,6 +374,38 @@ Since logger substitution has appeared in NestJS@8 the main purpose of `Logger`
 
 With such change it's not possible to detect if method was called by app internaly and the last argument is context or `Logger` was injected in some service via `constructor(private logger: Logger) {}` and the last argument is interpolation value for example.
 
+## Assign extra fields for future calls
+
+You can enrich logs before calling log methods. It's possible by using `assign` method of `PinoLogger` instance. As `Logger` class is used only for NestJS built-in `Logger` substitution via `app.useLogger(...)` this feature is only limited to `PinoLogger` class. Example:
+
+```ts
+
+@Controller('/')
+class TestController {
+  constructor(
+    private readonly logger: PinoLogger,
+    private readonly service: MyService,
+  ) {}
+
+  @Get()
+  get() {
+    // assign extra fields in one place...
+    this.logger.assign({ userID: '42' });
+    return this.service.test();
+  }
+}
+
+@Injectable()
+class MyService {
+  private readonly logger = new Logger(MyService.name);
+
+  test() {
+    // ...and it will be logged in another one
+    this.logger.log('hello world');
+  }
+}
+```
+
 ## Migration
 
 ### v1
diff --git a/src/LoggerModule.ts b/src/LoggerModule.ts
index cab4b53..add2a1d 100644
--- a/src/LoggerModule.ts
+++ b/src/LoggerModule.ts
@@ -18,7 +18,7 @@ import {
 } from './params';
 import { PinoLogger } from './PinoLogger';
 import { createProvidersForDecorated } from './InjectPinoLogger';
-import { storage } from './storage';
+import { Store, storage } from './storage';
 
 const DEFAULT_ROUTES = [{ path: '*', method: RequestMethod.ALL }];
 
@@ -112,5 +112,5 @@ function bindLoggerMiddleware(
 ) {
   // @ts-ignore: run requires arguments for next but should not because it can
   // be called without arguments
-  storage.run(req.log, next);
+  storage.run(new Store(req.log), next);
 }
diff --git a/src/PinoLogger.ts b/src/PinoLogger.ts
index d2f3c32..a565392 100644
--- a/src/PinoLogger.ts
+++ b/src/PinoLogger.ts
@@ -130,7 +130,17 @@ export class PinoLogger implements PinoMethods {
   public get logger(): pino.Logger {
     // outOfContext is always set in runtime before starts using
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-    return storage.getStore() || outOfContext!;
+    return storage.getStore()?.logger || outOfContext!;
+  }
+
+  public assign(fields: pino.Bindings) {
+    const store = storage.getStore();
+    if (!store) {
+      throw new Error(
+        `${PinoLogger.name}: unable to assign extra fields out of request scope`,
+      );
+    }
+    store.logger = store.logger.child(fields);
   }
 }
 
diff --git a/src/storage.ts b/src/storage.ts
index 381230f..4f8d3ac 100644
--- a/src/storage.ts
+++ b/src/storage.ts
@@ -1,4 +1,8 @@
 import { AsyncLocalStorage } from 'async_hooks';
 import { Logger } from 'pino';
 
-export const storage = new AsyncLocalStorage<Logger>();
+export class Store {
+  constructor(public logger: Logger) {}
+}
+
+export const storage = new AsyncLocalStorage<Store>();


Test patch:
diff --git a/__tests__/assign.spec.ts b/__tests__/assign.spec.ts
new file mode 100644
index 0000000..2dd559b
--- /dev/null
+++ b/__tests__/assign.spec.ts
@@ -0,0 +1,95 @@
+import {
+  Controller,
+  Get,
+  Injectable,
+  Logger,
+  OnModuleInit,
+} from '@nestjs/common';
+import { PinoLogger } from '../src';
+import { platforms } from './utils/platforms';
+import { TestCase } from './utils/test-case';
+
+describe('assign', () => {
+  for (const PlatformAdapter of platforms) {
+    describe(PlatformAdapter.name, () => {
+      it('in request context', async () => {
+        const msg = Math.random().toString();
+
+        @Injectable()
+        class TestService {
+          private readonly logger = new Logger(TestService.name);
+
+          test() {
+            this.logger.log(msg);
+            return {};
+          }
+        }
+
+        @Controller('/')
+        class TestController {
+          constructor(
+            private readonly logger: PinoLogger,
+            private readonly service: TestService,
+          ) {}
+
+          @Get()
+          get() {
+            this.logger.assign({ foo: 'bar' });
+            return this.service.test();
+          }
+        }
+
+        const logs = await new TestCase(new PlatformAdapter(), {
+          controllers: [TestController],
+          providers: [TestService],
+        })
+          .forRoot()
+          .run();
+
+        const wanted = logs.some((l) => l.msg === msg && l.foo === 'bar');
+        expect(wanted).toBeTruthy();
+      });
+
+      it('out of request context', async () => {
+        const msg = Math.random().toString();
+
+        @Injectable()
+        class TestService implements OnModuleInit {
+          constructor(private readonly logger: PinoLogger) {
+            logger.setContext(TestService.name);
+          }
+
+          onModuleInit() {
+            expect(() => this.logger.assign({ foo: 'bar' })).toThrow();
+          }
+
+          test() {
+            this.logger.info(msg);
+            return {};
+          }
+        }
+
+        @Controller('/')
+        class TestController {
+          constructor(private readonly service: TestService) {}
+
+          @Get()
+          get() {
+            return this.service.test();
+          }
+        }
+
+        const logs = await new TestCase(new PlatformAdapter(), {
+          controllers: [TestController],
+          providers: [TestService],
+        })
+          .forRoot()
+          .run();
+
+        const log = logs.find((l) => l.msg === msg);
+        expect(log).toBeTruthy();
+        expect(log).not.toHaveProperty('foo');
+      });
+    });
+  }
+});