From 97769d1e70dc4c4e4cc589b5f31a8338620ba3dc Mon Sep 17 00:00:00 2001 From: Valentin Cocaud Date: Fri, 18 Apr 2025 22:09:21 +0200 Subject: [PATCH 001/157] feat(opentelemetry): add support for b3 + jaeger + custom propagators (#967) From c5a5ac5b067976b7bff0e822c0fd93fe3b618adb Mon Sep 17 00:00:00 2001 From: Valentin Cocaud Date: Fri, 18 Apr 2025 22:09:21 +0200 Subject: [PATCH 002/157] feat(opentelemetry): add support for b3 + jaeger + custom propagators (#967) From c07fa7b823a681b9eb0cf1d36e33a687a08da971 Mon Sep 17 00:00:00 2001 From: Valentin Cocaud Date: Fri, 18 Apr 2025 22:09:21 +0200 Subject: [PATCH 003/157] feat(opentelemetry): add support for b3 + jaeger + custom propagators (#967) From 018f511b00356e6bbc240b4ab1a4c62614fe8646 Mon Sep 17 00:00:00 2001 From: Valentin Cocaud Date: Fri, 18 Apr 2025 22:09:21 +0200 Subject: [PATCH 004/157] feat(opentelemetry): add support for b3 + jaeger + custom propagators (#967) From e6d749dec389558d3a55807f5d6618cb3a793ae7 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 27 Mar 2025 13:31:40 +0100 Subject: [PATCH 005/157] starter pack --- packages/logger/package.json | 45 +++++++++++++++++++++++++++++++++++ packages/logger/src/Logger.ts | 3 +++ packages/logger/src/index.ts | 1 + yarn.lock | 8 +++++++ 4 files changed, 57 insertions(+) create mode 100644 packages/logger/package.json create mode 100644 packages/logger/src/Logger.ts create mode 100644 packages/logger/src/index.ts diff --git a/packages/logger/package.json b/packages/logger/package.json new file mode 100644 index 000000000..8fc2bf2ed --- /dev/null +++ b/packages/logger/package.json @@ -0,0 +1,45 @@ +{ + "name": "@graphql-hive/logger", + "version": "1.0.0", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/graphql-hive/gateway.git", + "directory": "packages/logger" + }, + "author": { + "email": "contact@the-guild.dev", + "name": "The Guild", + "url": "https://the-guild.dev" + }, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "main": "./dist/index.js", + "exports": { + ".": { + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + }, + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "./package.json": "./package.json" + }, + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "pkgroll --clean-dist", + "prepack": "yarn build" + }, + "devDependencies": { + "pkgroll": "2.11.2" + }, + "sideEffects": false +} diff --git a/packages/logger/src/Logger.ts b/packages/logger/src/Logger.ts new file mode 100644 index 000000000..946cc738e --- /dev/null +++ b/packages/logger/src/Logger.ts @@ -0,0 +1,3 @@ +export class Logger { + // +} diff --git a/packages/logger/src/index.ts b/packages/logger/src/index.ts new file mode 100644 index 000000000..58309e335 --- /dev/null +++ b/packages/logger/src/index.ts @@ -0,0 +1 @@ +export * from './Logger'; diff --git a/yarn.lock b/yarn.lock index d7e011c3f..d886ae0ea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4122,6 +4122,14 @@ __metadata: languageName: unknown linkType: soft +"@graphql-hive/logger@workspace:packages/logger": + version: 0.0.0-use.local + resolution: "@graphql-hive/logger@workspace:packages/logger" + dependencies: + pkgroll: "npm:2.11.2" + languageName: unknown + linkType: soft + "@graphql-hive/nestjs@workspace:^, @graphql-hive/nestjs@workspace:packages/nestjs": version: 0.0.0-use.local resolution: "@graphql-hive/nestjs@workspace:packages/nestjs" From a50fe2602ce8507f4b5a7f131773214741e6828b Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 27 Mar 2025 18:07:00 +0100 Subject: [PATCH 006/157] more begin --- packages/logger/src/Logger.ts | 159 +++++++++++++++++++++++++++++++++- packages/logger/src/utils.ts | 22 +++++ 2 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 packages/logger/src/utils.ts diff --git a/packages/logger/src/Logger.ts b/packages/logger/src/Logger.ts index 946cc738e..c4e7ab3a7 100644 --- a/packages/logger/src/Logger.ts +++ b/packages/logger/src/Logger.ts @@ -1,3 +1,160 @@ -export class Logger { +import { isPromise, jsonStringify } from './utils'; + +type Context = Record; + +type Attributes = Record; + +export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error'; + +export interface LogWriter { + write( + level: LogLevel, + msg: string, + attrs: Attributes | undefined, + ): void | Promise; + flush(): void | Promise; +} + +export class ConsoleLogWriter implements LogWriter { + write(level: LogLevel, msg: string, attrs: Attributes): void { + switch (level) { + // TODO: other levels + default: + console.log(msg, attrs ? jsonStringify(attrs) : undefined); + } + } + flush() { + // noop + } +} + +export class Logger implements LogWriter { + /** Hidden symbol used as a key for appending context attributes. */ + static #CTX_ATTRS_SY = Symbol('LOGGER_CONTEXT_ATTRIBUTES'); + + /** + * Gets the attributes from the {@link context ctx} under the hidden logger symbol key. + */ + static getCtxAttrs(ctx: Context): Attributes | undefined { + const metadata = ctx[Logger.#CTX_ATTRS_SY]; + // @ts-expect-error should the type be enforced on runtime? + return metadata; + } + + /** + * Mutates the {@link ctx context object} in place adding the {@link attrs attributes} under + * the hidden logger symbol key. + */ + static setAttrsInCtx(ctx: Context, attrs: Attributes) { + ctx[Logger.#CTX_ATTRS_SY] = { + // @ts-expect-error this should either be the attributes or undefined + ...ctx[Logger.CTX_ATTRS_SY], + ...attrs, + }; + } + + // + + #writers: LogWriter[]; + #pendingWrites = new Set>(); + + // TODO: logs for specific level + + constructor(writer: LogWriter, ...additionalWriters: LogWriter[]) { + this.#writers = [writer, ...additionalWriters]; + } + + public write( + level: LogLevel, + msg: string, + attrs: Attributes | undefined, + ): void { + const pendingWrites = this.#writers + .map((writer) => writer.write(level, msg, attrs)) + .filter(isPromise); + + for (const pendingWrite of pendingWrites) { + this.#pendingWrites.add(pendingWrite); + pendingWrite.catch(() => { + // TODO: what to do if the async write failed? + }); + pendingWrite.finally(() => this.#pendingWrites.delete(pendingWrite)); + } + } + + public flush() { + if (this.#pendingWrites.size) { + return Promise.all(this.#pendingWrites).then(() => { + // void + }); + } + return; + } + // + + public logCtx( + level: LogLevel, + ctx: Context, + attrs: Attributes, + msg: string, + ...interpolationValues: unknown[] + ): void; + public logCtx( + level: LogLevel, + ctx: Context, + msg: string, + ...interpolationValues: unknown[] + ): void; + public logCtx( + level: LogLevel, + ctx: Context, + attrsOrMsg: Attributes | string, + ...rest: unknown[] + ): void { + let msg = ''; + let attrs = Logger.getCtxAttrs(ctx); + if (attrsOrMsg instanceof Object) { + attrs = { ...attrs, ...attrsOrMsg }; + msg = rest.shift() + ''; // as per the overload, the first rest value is the message. TODO: enforce in runtime? + } else { + msg = attrsOrMsg; + } + if (attrs) { + this.log(level, attrs, msg, ...rest); + } else { + this.log(level, msg, ...rest); + } + } + + public log( + level: LogLevel, + attrs: Attributes, + msg: string, + ...interpolationValues: unknown[] + ): void; + public log( + level: LogLevel, + msg: string, + ...interpolationValues: unknown[] + ): void; + public log( + level: LogLevel, + attrsOrMsg: Attributes | string, + ...rest: unknown[] + ): void { + let msg = ''; + let attrs: Attributes | undefined; + if (attrsOrMsg instanceof Object) { + attrs = attrsOrMsg; + msg = rest.shift() + ''; // as per the overload, the first rest value is the message. TODO: enforce in runtime? + } else { + msg = attrsOrMsg; + } + + // @ts-expect-error TODO: interpolate values into the message + const interpolationValues = rest; + + this.write(level, msg, attrs); + } } diff --git a/packages/logger/src/utils.ts b/packages/logger/src/utils.ts new file mode 100644 index 000000000..4049047f7 --- /dev/null +++ b/packages/logger/src/utils.ts @@ -0,0 +1,22 @@ +export function isPromise(val: unknown): val is Promise { + const obj = Object(val); + return ( + typeof obj.then === 'function' && + typeof obj.catch === 'function' && + typeof obj.finally === 'function' + ); +} + +/** An error safe JSON stringifyer. */ +export function jsonStringify(val: unknown) { + return JSON.stringify(val, (_key, val) => { + if (val instanceof Error) { + return { + name: val.name, + message: val.message, + stack: val.stack, + }; + } + return val; + }); +} From 2c0a8f858704c416ed761bc85d2a1047ad99b738 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 27 Mar 2025 18:09:07 +0100 Subject: [PATCH 007/157] move around --- packages/logger/src/Logger.ts | 29 ++--------------------------- packages/logger/src/index.ts | 1 + packages/logger/src/utils.ts | 4 ++++ packages/logger/src/writers.ts | 24 ++++++++++++++++++++++++ 4 files changed, 31 insertions(+), 27 deletions(-) create mode 100644 packages/logger/src/writers.ts diff --git a/packages/logger/src/Logger.ts b/packages/logger/src/Logger.ts index c4e7ab3a7..5163b15f0 100644 --- a/packages/logger/src/Logger.ts +++ b/packages/logger/src/Logger.ts @@ -1,33 +1,8 @@ -import { isPromise, jsonStringify } from './utils'; - -type Context = Record; - -type Attributes = Record; +import { Attributes, Context, isPromise } from './utils'; +import { LogWriter } from './writers'; export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error'; -export interface LogWriter { - write( - level: LogLevel, - msg: string, - attrs: Attributes | undefined, - ): void | Promise; - flush(): void | Promise; -} - -export class ConsoleLogWriter implements LogWriter { - write(level: LogLevel, msg: string, attrs: Attributes): void { - switch (level) { - // TODO: other levels - default: - console.log(msg, attrs ? jsonStringify(attrs) : undefined); - } - } - flush() { - // noop - } -} - export class Logger implements LogWriter { /** Hidden symbol used as a key for appending context attributes. */ static #CTX_ATTRS_SY = Symbol('LOGGER_CONTEXT_ATTRIBUTES'); diff --git a/packages/logger/src/index.ts b/packages/logger/src/index.ts index 58309e335..c5430cdd9 100644 --- a/packages/logger/src/index.ts +++ b/packages/logger/src/index.ts @@ -1 +1,2 @@ export * from './Logger'; +export * from './writers'; diff --git a/packages/logger/src/utils.ts b/packages/logger/src/utils.ts index 4049047f7..f131b2794 100644 --- a/packages/logger/src/utils.ts +++ b/packages/logger/src/utils.ts @@ -1,3 +1,7 @@ +export type Context = Record; + +export type Attributes = Record; + export function isPromise(val: unknown): val is Promise { const obj = Object(val); return ( diff --git a/packages/logger/src/writers.ts b/packages/logger/src/writers.ts new file mode 100644 index 000000000..1f69b3557 --- /dev/null +++ b/packages/logger/src/writers.ts @@ -0,0 +1,24 @@ +import { LogLevel } from './Logger'; +import { Attributes, jsonStringify } from './utils'; + +export interface LogWriter { + write( + level: LogLevel, + msg: string, + attrs: Attributes | undefined, + ): void | Promise; + flush(): void | Promise; +} + +export class ConsoleLogWriter implements LogWriter { + write(level: LogLevel, msg: string, attrs: Attributes): void { + switch (level) { + // TODO: other levels + default: + console.log(msg, attrs ? jsonStringify(attrs) : undefined); + } + } + flush() { + // noop + } +} From 371285bdc91c98dd8d3b382e6a0d3ca15a0d96ed Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 27 Mar 2025 18:56:58 +0100 Subject: [PATCH 008/157] basic tests --- packages/logger/src/Logger.ts | 12 +++--- packages/logger/src/utils.ts | 19 +++++++++ packages/logger/tests/Logger.test.ts | 62 ++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 6 deletions(-) create mode 100644 packages/logger/tests/Logger.test.ts diff --git a/packages/logger/src/Logger.ts b/packages/logger/src/Logger.ts index 5163b15f0..2c94397ec 100644 --- a/packages/logger/src/Logger.ts +++ b/packages/logger/src/Logger.ts @@ -10,7 +10,7 @@ export class Logger implements LogWriter { /** * Gets the attributes from the {@link context ctx} under the hidden logger symbol key. */ - static getCtxAttrs(ctx: Context): Attributes | undefined { + public getCtxAttrs(ctx: Context): Attributes | undefined { const metadata = ctx[Logger.#CTX_ATTRS_SY]; // @ts-expect-error should the type be enforced on runtime? return metadata; @@ -20,7 +20,7 @@ export class Logger implements LogWriter { * Mutates the {@link ctx context object} in place adding the {@link attrs attributes} under * the hidden logger symbol key. */ - static setAttrsInCtx(ctx: Context, attrs: Attributes) { + public setAttrsInCtx(ctx: Context, attrs: Attributes) { ctx[Logger.#CTX_ATTRS_SY] = { // @ts-expect-error this should either be the attributes or undefined ...ctx[Logger.CTX_ATTRS_SY], @@ -69,26 +69,26 @@ export class Logger implements LogWriter { // public logCtx( - level: LogLevel, ctx: Context, + level: LogLevel, attrs: Attributes, msg: string, ...interpolationValues: unknown[] ): void; public logCtx( - level: LogLevel, ctx: Context, + level: LogLevel, msg: string, ...interpolationValues: unknown[] ): void; public logCtx( - level: LogLevel, ctx: Context, + level: LogLevel, attrsOrMsg: Attributes | string, ...rest: unknown[] ): void { let msg = ''; - let attrs = Logger.getCtxAttrs(ctx); + let attrs = this.getCtxAttrs(ctx); if (attrsOrMsg instanceof Object) { attrs = { ...attrs, ...attrsOrMsg }; msg = rest.shift() + ''; // as per the overload, the first rest value is the message. TODO: enforce in runtime? diff --git a/packages/logger/src/utils.ts b/packages/logger/src/utils.ts index f131b2794..ad9a224f0 100644 --- a/packages/logger/src/utils.ts +++ b/packages/logger/src/utils.ts @@ -1,7 +1,26 @@ +import { LogLevel } from './Logger'; + export type Context = Record; export type Attributes = Record; +export function logLevelToString(level: LogLevel): string { + switch (level) { + case 'trace': + return 'TRC'; + case 'debug': + return 'DBG'; + case 'info': + return 'INF'; + case 'warn': + return 'WRN'; + case 'error': + return 'ERR'; + default: + throw new Error(`Unknown log level "${level}"`); + } +} + export function isPromise(val: unknown): val is Promise { const obj = Object(val); return ( diff --git a/packages/logger/tests/Logger.test.ts b/packages/logger/tests/Logger.test.ts new file mode 100644 index 000000000..6a2b0fb42 --- /dev/null +++ b/packages/logger/tests/Logger.test.ts @@ -0,0 +1,62 @@ +import { expect, it } from 'vitest'; +import { Logger, LogLevel } from '../src/Logger'; +import { LogWriter } from '../src/writers'; + +class TLogWriter implements LogWriter { + public logs: { level: LogLevel; msg: string; attrs: unknown }[] = []; + + write(level: LogLevel, msg: string, attrs: Record): void { + this.logs.push({ level, msg, attrs }); + } + + flush(): void { + // noop + } +} + +function createTLogger() { + const writter = new TLogWriter(); + return [new Logger(writter), writter] as const; +} + +it('should write logs with levels, message and attributes', () => { + const [logger, writter] = createTLogger(); + logger.log( + 'info', + { hello: 'world', err: new Error('Woah!') }, + 'Hello, world!', + ); + expect(writter.logs).toMatchInlineSnapshot(` + [ + { + "attrs": { + "err": [Error: Woah!], + "hello": "world", + }, + "level": "info", + "msg": "Hello, world!", + }, + ] + `); +}); + +it('should write logs with attributes in context', () => { + const [logger, writter] = createTLogger(); + + const ctx = {}; + logger.setAttrsInCtx(ctx, { hello: 'world' }); + + logger.logCtx(ctx, 'info', { world: 'hello' }, 'Hello, world!'); + expect(writter.logs).toMatchInlineSnapshot(` + [ + { + "attrs": { + "hello": "world", + "world": "hello", + }, + "level": "info", + "msg": "Hello, world!", + }, + ] + `); +}); From 74c42c838fbc3f0e08dc6a8f7182dc69f1432521 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 27 Mar 2025 18:57:39 +0100 Subject: [PATCH 009/157] todo --- packages/logger/src/Logger.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/logger/src/Logger.ts b/packages/logger/src/Logger.ts index 2c94397ec..60199d9d4 100644 --- a/packages/logger/src/Logger.ts +++ b/packages/logger/src/Logger.ts @@ -66,6 +66,8 @@ export class Logger implements LogWriter { return; } + // TODO: flush on dispose + // public logCtx( From 350a4ff4ccb884554e60c8fdd73d09521a9ae1d6 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 27 Mar 2025 19:02:44 +0100 Subject: [PATCH 010/157] test no attrs --- packages/logger/tests/Logger.test.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/logger/tests/Logger.test.ts b/packages/logger/tests/Logger.test.ts index 6a2b0fb42..93bdb5fca 100644 --- a/packages/logger/tests/Logger.test.ts +++ b/packages/logger/tests/Logger.test.ts @@ -3,10 +3,10 @@ import { Logger, LogLevel } from '../src/Logger'; import { LogWriter } from '../src/writers'; class TLogWriter implements LogWriter { - public logs: { level: LogLevel; msg: string; attrs: unknown }[] = []; + public logs: { level: LogLevel; msg: string; attrs?: unknown }[] = []; write(level: LogLevel, msg: string, attrs: Record): void { - this.logs.push({ level, msg, attrs }); + this.logs.push({ level, msg, ...(attrs ? { attrs } : {}) }); } flush(): void { @@ -21,11 +21,14 @@ function createTLogger() { it('should write logs with levels, message and attributes', () => { const [logger, writter] = createTLogger(); + logger.log( 'info', { hello: 'world', err: new Error('Woah!') }, 'Hello, world!', ); + logger.log('info', '2nd Hello, world!'); + expect(writter.logs).toMatchInlineSnapshot(` [ { @@ -36,6 +39,10 @@ it('should write logs with levels, message and attributes', () => { "level": "info", "msg": "Hello, world!", }, + { + "level": "info", + "msg": "2nd Hello, world!", + }, ] `); }); @@ -47,6 +54,8 @@ it('should write logs with attributes in context', () => { logger.setAttrsInCtx(ctx, { hello: 'world' }); logger.logCtx(ctx, 'info', { world: 'hello' }, 'Hello, world!'); + logger.logCtx(ctx, 'info', '2nd Hello, world!'); + expect(writter.logs).toMatchInlineSnapshot(` [ { @@ -57,6 +66,13 @@ it('should write logs with attributes in context', () => { "level": "info", "msg": "Hello, world!", }, + { + "attrs": { + "hello": "world", + }, + "level": "info", + "msg": "2nd Hello, world!", + }, ] `); }); From 5a83f4ddcaae731175f3600f51758fca24f0c297 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 27 Mar 2025 19:07:39 +0100 Subject: [PATCH 011/157] logger options --- packages/logger/src/Logger.ts | 15 ++++++++++++--- packages/logger/tests/Logger.test.ts | 4 ++-- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/logger/src/Logger.ts b/packages/logger/src/Logger.ts index 60199d9d4..40760a22a 100644 --- a/packages/logger/src/Logger.ts +++ b/packages/logger/src/Logger.ts @@ -1,8 +1,17 @@ import { Attributes, Context, isPromise } from './utils'; -import { LogWriter } from './writers'; +import { ConsoleLogWriter, LogWriter } from './writers'; export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error'; +export interface LoggerOptions { + /** + * The log writers to use when writing logs. + * + * @default [new ConsoleLogWriter()] + */ + writers: [LogWriter, ...LogWriter[]]; +} + export class Logger implements LogWriter { /** Hidden symbol used as a key for appending context attributes. */ static #CTX_ATTRS_SY = Symbol('LOGGER_CONTEXT_ATTRIBUTES'); @@ -35,8 +44,8 @@ export class Logger implements LogWriter { // TODO: logs for specific level - constructor(writer: LogWriter, ...additionalWriters: LogWriter[]) { - this.#writers = [writer, ...additionalWriters]; + constructor(opts: LoggerOptions = { writers: [new ConsoleLogWriter()] }) { + this.#writers = opts.writers; } public write( diff --git a/packages/logger/tests/Logger.test.ts b/packages/logger/tests/Logger.test.ts index 93bdb5fca..12dfbf212 100644 --- a/packages/logger/tests/Logger.test.ts +++ b/packages/logger/tests/Logger.test.ts @@ -15,8 +15,8 @@ class TLogWriter implements LogWriter { } function createTLogger() { - const writter = new TLogWriter(); - return [new Logger(writter), writter] as const; + const writer = new TLogWriter(); + return [new Logger({ writers: [writer] }), writer] as const; } it('should write logs with levels, message and attributes', () => { From 5277e428906f37d4a5197308aa7911c9e7a1a33d Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 27 Mar 2025 19:09:30 +0100 Subject: [PATCH 012/157] symbol things --- packages/logger/src/Logger.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/logger/src/Logger.ts b/packages/logger/src/Logger.ts index 40760a22a..821a6315c 100644 --- a/packages/logger/src/Logger.ts +++ b/packages/logger/src/Logger.ts @@ -13,8 +13,12 @@ export interface LoggerOptions { } export class Logger implements LogWriter { - /** Hidden symbol used as a key for appending context attributes. */ - static #CTX_ATTRS_SY = Symbol('LOGGER_CONTEXT_ATTRIBUTES'); + /** + * Hidden symbol used as a key for appending context attributes for all loggers. + * + * TODO: should the symbol be scoped for a specific logger? + */ + static #CTX_ATTRS_SY = Symbol('hive.logger.context.attributes'); /** * Gets the attributes from the {@link context ctx} under the hidden logger symbol key. From d1856e2f7e4afc44a876ae7eed8e7743bacff50b Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 27 Mar 2025 19:39:34 +0100 Subject: [PATCH 013/157] allow any object as context --- packages/logger/src/Logger.ts | 9 +++------ packages/logger/src/utils.ts | 2 +- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/logger/src/Logger.ts b/packages/logger/src/Logger.ts index 821a6315c..9aa2c1177 100644 --- a/packages/logger/src/Logger.ts +++ b/packages/logger/src/Logger.ts @@ -24,9 +24,7 @@ export class Logger implements LogWriter { * Gets the attributes from the {@link context ctx} under the hidden logger symbol key. */ public getCtxAttrs(ctx: Context): Attributes | undefined { - const metadata = ctx[Logger.#CTX_ATTRS_SY]; - // @ts-expect-error should the type be enforced on runtime? - return metadata; + return Object(ctx)[Logger.#CTX_ATTRS_SY]; } /** @@ -34,9 +32,8 @@ export class Logger implements LogWriter { * the hidden logger symbol key. */ public setAttrsInCtx(ctx: Context, attrs: Attributes) { - ctx[Logger.#CTX_ATTRS_SY] = { - // @ts-expect-error this should either be the attributes or undefined - ...ctx[Logger.CTX_ATTRS_SY], + Object(ctx)[Logger.#CTX_ATTRS_SY] = { + ...this.getCtxAttrs(ctx), ...attrs, }; } diff --git a/packages/logger/src/utils.ts b/packages/logger/src/utils.ts index ad9a224f0..f631b36f8 100644 --- a/packages/logger/src/utils.ts +++ b/packages/logger/src/utils.ts @@ -1,6 +1,6 @@ import { LogLevel } from './Logger'; -export type Context = Record; +export type Context = Object; export type Attributes = Record; From 5e2e48736837c3f59f6897c6c026ae15272396ec Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 27 Mar 2025 19:47:21 +0100 Subject: [PATCH 014/157] more methods --- packages/logger/src/Logger.ts | 128 ++++++++++++++++++++++++++- packages/logger/tests/Logger.test.ts | 4 +- 2 files changed, 127 insertions(+), 5 deletions(-) diff --git a/packages/logger/src/Logger.ts b/packages/logger/src/Logger.ts index 9aa2c1177..c0eb8c8a8 100644 --- a/packages/logger/src/Logger.ts +++ b/packages/logger/src/Logger.ts @@ -81,21 +81,21 @@ export class Logger implements LogWriter { // public logCtx( - ctx: Context, level: LogLevel, + ctx: Context, attrs: Attributes, msg: string, ...interpolationValues: unknown[] ): void; public logCtx( - ctx: Context, level: LogLevel, + ctx: Context, msg: string, ...interpolationValues: unknown[] ): void; public logCtx( - ctx: Context, level: LogLevel, + ctx: Context, attrsOrMsg: Attributes | string, ...rest: unknown[] ): void { @@ -130,6 +130,8 @@ export class Logger implements LogWriter { attrsOrMsg: Attributes | string, ...rest: unknown[] ): void { + // TODO: validate types on runtime, or not? + let msg = ''; let attrs: Attributes | undefined; if (attrsOrMsg instanceof Object) { @@ -144,4 +146,124 @@ export class Logger implements LogWriter { this.write(level, msg, attrs); } + + public traceCtx( + ctx: Context, + attrs: Attributes, + msg: string, + ...interpolationValues: unknown[] + ): void; + public traceCtx( + ctx: Context, + msg: string, + ...interpolationValues: unknown[] + ): void; + public traceCtx(...args: [ctx: Context, arg0: any, ...rest: any[]]): void { + this.logCtx('trace', ...args); + } + public trace( + attrs: Attributes, + msg: string, + ...interpolationValues: unknown[] + ): void; + public trace(msg: string, ...interpolationValues: unknown[]): void; + public trace(...args: [arg0: any, ...rest: any[]]): void { + this.log('trace', ...args); + } + + public debugCtx( + ctx: Context, + attrs: Attributes, + msg: string, + ...interpolationValues: unknown[] + ): void; + public debugCtx( + ctx: Context, + msg: string, + ...interpolationValues: unknown[] + ): void; + public debugCtx(...args: [ctx: Context, arg0: any, ...rest: any[]]): void { + this.logCtx('debug', ...args); + } + public debug( + attrs: Attributes, + msg: string, + ...interpolationValues: unknown[] + ): void; + public debug(msg: string, ...interpolationValues: unknown[]): void; + public debug(...args: [arg0: any, ...rest: any[]]): void { + this.log('debug', ...args); + } + + public infoCtx( + ctx: Context, + attrs: Attributes, + msg: string, + ...interpolationValues: unknown[] + ): void; + public infoCtx( + ctx: Context, + msg: string, + ...interpolationValues: unknown[] + ): void; + public infoCtx(...args: [ctx: Context, arg0: any, ...rest: any[]]): void { + this.logCtx('info', ...args); + } + public info( + attrs: Attributes, + msg: string, + ...interpolationValues: unknown[] + ): void; + public info(msg: string, ...interpolationValues: unknown[]): void; + public info(...args: [arg0: any, ...rest: any[]]): void { + this.log('info', ...args); + } + + public warnCtx( + ctx: Context, + attrs: Attributes, + msg: string, + ...interpolationValues: unknown[] + ): void; + public warnCtx( + ctx: Context, + msg: string, + ...interpolationValues: unknown[] + ): void; + public warnCtx(...args: [ctx: Context, arg0: any, ...rest: any[]]): void { + this.logCtx('warn', ...args); + } + public warn( + attrs: Attributes, + msg: string, + ...interpolationValues: unknown[] + ): void; + public warn(msg: string, ...interpolationValues: unknown[]): void; + public warn(...args: [arg0: any, ...rest: any[]]): void { + this.log('warn', ...args); + } + + public errorCtx( + ctx: Context, + attrs: Attributes, + msg: string, + ...interpolationValues: unknown[] + ): void; + public errorCtx( + ctx: Context, + msg: string, + ...interpolationValues: unknown[] + ): void; + public errorCtx(...args: [ctx: Context, arg0: any, ...rest: any[]]): void { + this.logCtx('error', ...args); + } + public error( + attrs: Attributes, + msg: string, + ...interpolationValues: unknown[] + ): void; + public error(msg: string, ...interpolationValues: unknown[]): void; + public error(...args: [arg0: any, ...rest: any[]]): void { + this.log('error', ...args); + } } diff --git a/packages/logger/tests/Logger.test.ts b/packages/logger/tests/Logger.test.ts index 12dfbf212..c01d65f53 100644 --- a/packages/logger/tests/Logger.test.ts +++ b/packages/logger/tests/Logger.test.ts @@ -53,8 +53,8 @@ it('should write logs with attributes in context', () => { const ctx = {}; logger.setAttrsInCtx(ctx, { hello: 'world' }); - logger.logCtx(ctx, 'info', { world: 'hello' }, 'Hello, world!'); - logger.logCtx(ctx, 'info', '2nd Hello, world!'); + logger.logCtx('info', ctx, { world: 'hello' }, 'Hello, world!'); + logger.logCtx('info', ctx, '2nd Hello, world!'); expect(writter.logs).toMatchInlineSnapshot(` [ From d832e7d8ed8520b30c2c20e0650e5ab2167ea811 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 27 Mar 2025 19:49:07 +0100 Subject: [PATCH 015/157] todo --- packages/logger/src/writers.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/logger/src/writers.ts b/packages/logger/src/writers.ts index 1f69b3557..d7234a9a5 100644 --- a/packages/logger/src/writers.ts +++ b/packages/logger/src/writers.ts @@ -15,6 +15,7 @@ export class ConsoleLogWriter implements LogWriter { switch (level) { // TODO: other levels default: + // TODO: write log level and time console.log(msg, attrs ? jsonStringify(attrs) : undefined); } } From fc21686cfb90f3d0186251da06b4a9b5f3d20369 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 27 Mar 2025 20:00:59 +0100 Subject: [PATCH 016/157] some todos and attrval --- packages/logger/src/Logger.ts | 4 ++++ packages/logger/src/utils.ts | 14 +++++++++++++- packages/logger/tests/Logger.test.ts | 2 +- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/logger/src/Logger.ts b/packages/logger/src/Logger.ts index c0eb8c8a8..5e2148172 100644 --- a/packages/logger/src/Logger.ts +++ b/packages/logger/src/Logger.ts @@ -132,6 +132,8 @@ export class Logger implements LogWriter { ): void { // TODO: validate types on runtime, or not? + // TODO: log only if level is enabled + let msg = ''; let attrs: Attributes | undefined; if (attrsOrMsg instanceof Object) { @@ -141,6 +143,8 @@ export class Logger implements LogWriter { msg = attrsOrMsg; } + // TODO: unwrap lazy attribute values + // @ts-expect-error TODO: interpolate values into the message const interpolationValues = rest; diff --git a/packages/logger/src/utils.ts b/packages/logger/src/utils.ts index f631b36f8..0d3a7c0a0 100644 --- a/packages/logger/src/utils.ts +++ b/packages/logger/src/utils.ts @@ -1,8 +1,20 @@ import { LogLevel } from './Logger'; +/** Context can be any JavaScript object to which a property can be assigned; */ export type Context = Object; -export type Attributes = Record; +export type AttributeValue = + | string + | number + | boolean + | { [key: PropertyKey]: AttributeValue } + | AttributeValue[] + | Object // redundant, but this will allow _any_ object be the value + | null + | undefined + | (() => AttributeValue); // lazy attribute + +export type Attributes = Record; export function logLevelToString(level: LogLevel): string { switch (level) { diff --git a/packages/logger/tests/Logger.test.ts b/packages/logger/tests/Logger.test.ts index c01d65f53..78439166a 100644 --- a/packages/logger/tests/Logger.test.ts +++ b/packages/logger/tests/Logger.test.ts @@ -5,7 +5,7 @@ import { LogWriter } from '../src/writers'; class TLogWriter implements LogWriter { public logs: { level: LogLevel; msg: string; attrs?: unknown }[] = []; - write(level: LogLevel, msg: string, attrs: Record): void { + write(level: LogLevel, msg: string, attrs: Record): void { this.logs.push({ level, msg, ...(attrs ? { attrs } : {}) }); } From bfd683e989633a71f6385586f360ed4462d8bfb1 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 27 Mar 2025 20:02:19 +0100 Subject: [PATCH 017/157] docs --- packages/logger/src/writers.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/logger/src/writers.ts b/packages/logger/src/writers.ts index d7234a9a5..1ab1fde97 100644 --- a/packages/logger/src/writers.ts +++ b/packages/logger/src/writers.ts @@ -16,7 +16,11 @@ export class ConsoleLogWriter implements LogWriter { // TODO: other levels default: // TODO: write log level and time - console.log(msg, attrs ? jsonStringify(attrs) : undefined); + console.log( + msg, + // we want to stringify because we want all properties be properly displayed + attrs ? jsonStringify(attrs) : undefined, + ); } } flush() { From 8754b12cf3fd3ec646f4e961158c21eda2f25977 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Wed, 2 Apr 2025 17:55:40 +0200 Subject: [PATCH 018/157] parent attrs --- packages/logger/src/Logger.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/logger/src/Logger.ts b/packages/logger/src/Logger.ts index 5e2148172..c52b6fc55 100644 --- a/packages/logger/src/Logger.ts +++ b/packages/logger/src/Logger.ts @@ -4,6 +4,11 @@ import { ConsoleLogWriter, LogWriter } from './writers'; export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error'; export interface LoggerOptions { + /** + * The attributes to include in all logs. Is mainly used to pass the parent + * attributes when creating child loggers. + */ + attrs?: Attributes; /** * The log writers to use when writing logs. * @@ -40,12 +45,14 @@ export class Logger implements LogWriter { // + #attrs: Attributes | undefined; #writers: LogWriter[]; #pendingWrites = new Set>(); // TODO: logs for specific level constructor(opts: LoggerOptions = { writers: [new ConsoleLogWriter()] }) { + this.#attrs = opts.attrs; this.#writers = opts.writers; } @@ -108,7 +115,7 @@ export class Logger implements LogWriter { msg = attrsOrMsg; } if (attrs) { - this.log(level, attrs, msg, ...rest); + this.log(level, { ...this.#attrs, ...attrs }, msg, ...rest); } else { this.log(level, msg, ...rest); } @@ -148,7 +155,7 @@ export class Logger implements LogWriter { // @ts-expect-error TODO: interpolate values into the message const interpolationValues = rest; - this.write(level, msg, attrs); + this.write(level, msg, this.#attrs ? { ...this.#attrs, ...attrs } : attrs); } public traceCtx( From 9d8d0c80ddda89d5468cbed37f5d2c9efa75e2d8 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Wed, 2 Apr 2025 17:56:51 +0200 Subject: [PATCH 019/157] remove context --- packages/logger/src/Logger.ts | 133 +-------------------------- packages/logger/src/utils.ts | 3 - packages/logger/tests/Logger.test.ts | 30 ------ 3 files changed, 1 insertion(+), 165 deletions(-) diff --git a/packages/logger/src/Logger.ts b/packages/logger/src/Logger.ts index c52b6fc55..5facb38c4 100644 --- a/packages/logger/src/Logger.ts +++ b/packages/logger/src/Logger.ts @@ -1,4 +1,4 @@ -import { Attributes, Context, isPromise } from './utils'; +import { Attributes, isPromise } from './utils'; import { ConsoleLogWriter, LogWriter } from './writers'; export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error'; @@ -18,33 +18,6 @@ export interface LoggerOptions { } export class Logger implements LogWriter { - /** - * Hidden symbol used as a key for appending context attributes for all loggers. - * - * TODO: should the symbol be scoped for a specific logger? - */ - static #CTX_ATTRS_SY = Symbol('hive.logger.context.attributes'); - - /** - * Gets the attributes from the {@link context ctx} under the hidden logger symbol key. - */ - public getCtxAttrs(ctx: Context): Attributes | undefined { - return Object(ctx)[Logger.#CTX_ATTRS_SY]; - } - - /** - * Mutates the {@link ctx context object} in place adding the {@link attrs attributes} under - * the hidden logger symbol key. - */ - public setAttrsInCtx(ctx: Context, attrs: Attributes) { - Object(ctx)[Logger.#CTX_ATTRS_SY] = { - ...this.getCtxAttrs(ctx), - ...attrs, - }; - } - - // - #attrs: Attributes | undefined; #writers: LogWriter[]; #pendingWrites = new Set>(); @@ -87,40 +60,6 @@ export class Logger implements LogWriter { // - public logCtx( - level: LogLevel, - ctx: Context, - attrs: Attributes, - msg: string, - ...interpolationValues: unknown[] - ): void; - public logCtx( - level: LogLevel, - ctx: Context, - msg: string, - ...interpolationValues: unknown[] - ): void; - public logCtx( - level: LogLevel, - ctx: Context, - attrsOrMsg: Attributes | string, - ...rest: unknown[] - ): void { - let msg = ''; - let attrs = this.getCtxAttrs(ctx); - if (attrsOrMsg instanceof Object) { - attrs = { ...attrs, ...attrsOrMsg }; - msg = rest.shift() + ''; // as per the overload, the first rest value is the message. TODO: enforce in runtime? - } else { - msg = attrsOrMsg; - } - if (attrs) { - this.log(level, { ...this.#attrs, ...attrs }, msg, ...rest); - } else { - this.log(level, msg, ...rest); - } - } - public log( level: LogLevel, attrs: Attributes, @@ -158,20 +97,6 @@ export class Logger implements LogWriter { this.write(level, msg, this.#attrs ? { ...this.#attrs, ...attrs } : attrs); } - public traceCtx( - ctx: Context, - attrs: Attributes, - msg: string, - ...interpolationValues: unknown[] - ): void; - public traceCtx( - ctx: Context, - msg: string, - ...interpolationValues: unknown[] - ): void; - public traceCtx(...args: [ctx: Context, arg0: any, ...rest: any[]]): void { - this.logCtx('trace', ...args); - } public trace( attrs: Attributes, msg: string, @@ -182,20 +107,6 @@ export class Logger implements LogWriter { this.log('trace', ...args); } - public debugCtx( - ctx: Context, - attrs: Attributes, - msg: string, - ...interpolationValues: unknown[] - ): void; - public debugCtx( - ctx: Context, - msg: string, - ...interpolationValues: unknown[] - ): void; - public debugCtx(...args: [ctx: Context, arg0: any, ...rest: any[]]): void { - this.logCtx('debug', ...args); - } public debug( attrs: Attributes, msg: string, @@ -206,20 +117,6 @@ export class Logger implements LogWriter { this.log('debug', ...args); } - public infoCtx( - ctx: Context, - attrs: Attributes, - msg: string, - ...interpolationValues: unknown[] - ): void; - public infoCtx( - ctx: Context, - msg: string, - ...interpolationValues: unknown[] - ): void; - public infoCtx(...args: [ctx: Context, arg0: any, ...rest: any[]]): void { - this.logCtx('info', ...args); - } public info( attrs: Attributes, msg: string, @@ -230,20 +127,6 @@ export class Logger implements LogWriter { this.log('info', ...args); } - public warnCtx( - ctx: Context, - attrs: Attributes, - msg: string, - ...interpolationValues: unknown[] - ): void; - public warnCtx( - ctx: Context, - msg: string, - ...interpolationValues: unknown[] - ): void; - public warnCtx(...args: [ctx: Context, arg0: any, ...rest: any[]]): void { - this.logCtx('warn', ...args); - } public warn( attrs: Attributes, msg: string, @@ -254,20 +137,6 @@ export class Logger implements LogWriter { this.log('warn', ...args); } - public errorCtx( - ctx: Context, - attrs: Attributes, - msg: string, - ...interpolationValues: unknown[] - ): void; - public errorCtx( - ctx: Context, - msg: string, - ...interpolationValues: unknown[] - ): void; - public errorCtx(...args: [ctx: Context, arg0: any, ...rest: any[]]): void { - this.logCtx('error', ...args); - } public error( attrs: Attributes, msg: string, diff --git a/packages/logger/src/utils.ts b/packages/logger/src/utils.ts index 0d3a7c0a0..4ee8036f0 100644 --- a/packages/logger/src/utils.ts +++ b/packages/logger/src/utils.ts @@ -1,8 +1,5 @@ import { LogLevel } from './Logger'; -/** Context can be any JavaScript object to which a property can be assigned; */ -export type Context = Object; - export type AttributeValue = | string | number diff --git a/packages/logger/tests/Logger.test.ts b/packages/logger/tests/Logger.test.ts index 78439166a..f819b51c8 100644 --- a/packages/logger/tests/Logger.test.ts +++ b/packages/logger/tests/Logger.test.ts @@ -46,33 +46,3 @@ it('should write logs with levels, message and attributes', () => { ] `); }); - -it('should write logs with attributes in context', () => { - const [logger, writter] = createTLogger(); - - const ctx = {}; - logger.setAttrsInCtx(ctx, { hello: 'world' }); - - logger.logCtx('info', ctx, { world: 'hello' }, 'Hello, world!'); - logger.logCtx('info', ctx, '2nd Hello, world!'); - - expect(writter.logs).toMatchInlineSnapshot(` - [ - { - "attrs": { - "hello": "world", - "world": "hello", - }, - "level": "info", - "msg": "Hello, world!", - }, - { - "attrs": { - "hello": "world", - }, - "level": "info", - "msg": "2nd Hello, world!", - }, - ] - `); -}); From fd8eb8451595b10d6875ead6d6e1e6f4bd3210ab Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Wed, 2 Apr 2025 18:03:36 +0200 Subject: [PATCH 020/157] child --- packages/logger/src/Logger.ts | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/logger/src/Logger.ts b/packages/logger/src/Logger.ts index 5facb38c4..be2807743 100644 --- a/packages/logger/src/Logger.ts +++ b/packages/logger/src/Logger.ts @@ -4,6 +4,8 @@ import { ConsoleLogWriter, LogWriter } from './writers'; export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error'; export interface LoggerOptions { + /** A prefix to provide to every log's message. */ + prefix?: string; /** * The attributes to include in all logs. Is mainly used to pass the parent * attributes when creating child loggers. @@ -18,13 +20,15 @@ export interface LoggerOptions { } export class Logger implements LogWriter { + prefix: string | undefined; #attrs: Attributes | undefined; - #writers: LogWriter[]; + #writers: [LogWriter, ...LogWriter[]]; #pendingWrites = new Set>(); // TODO: logs for specific level constructor(opts: LoggerOptions = { writers: [new ConsoleLogWriter()] }) { + this.prefix = opts.prefix; this.#attrs = opts.attrs; this.#writers = opts.writers; } @@ -60,6 +64,25 @@ export class Logger implements LogWriter { // + public child(prefix: string): Logger; + public child(attrs: Attributes): Logger; + public child(prefixOrAttrs: string | Attributes, prefix?: string): Logger { + if (typeof prefixOrAttrs === 'string') { + prefix = prefixOrAttrs; + return new Logger({ + prefix: prefix, + writers: this.#writers, + }); + } + return new Logger({ + prefix: prefix, + attrs: prefixOrAttrs, + writers: this.#writers, + }); + } + + // + public log( level: LogLevel, attrs: Attributes, @@ -89,6 +112,10 @@ export class Logger implements LogWriter { msg = attrsOrMsg; } + if (this.prefix) { + msg = `${this.prefix.trim()} ${msg}`.trim(); // we trim everything because maybe the "msg" is empty + } + // TODO: unwrap lazy attribute values // @ts-expect-error TODO: interpolate values into the message From 694d399e584c94378e2a032d5a878161c585a99f Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Wed, 2 Apr 2025 18:06:30 +0200 Subject: [PATCH 021/157] todo --- packages/logger/src/utils.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/logger/src/utils.ts b/packages/logger/src/utils.ts index 4ee8036f0..bf385ea3c 100644 --- a/packages/logger/src/utils.ts +++ b/packages/logger/src/utils.ts @@ -43,6 +43,7 @@ export function isPromise(val: unknown): val is Promise { export function jsonStringify(val: unknown) { return JSON.stringify(val, (_key, val) => { if (val instanceof Error) { + // TODO: also handle graphql errors, and maybe all other errors that can contain more properties return { name: val.name, message: val.message, From 23bdfaaf020881ab5d3393cbe4c5670254a0979b Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 3 Apr 2025 16:11:27 +0200 Subject: [PATCH 022/157] loglgevel prefix --- packages/logger/src/Logger.ts | 43 ++++++++++++++++++++++++++--------- packages/logger/src/utils.ts | 4 ++-- 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/packages/logger/src/Logger.ts b/packages/logger/src/Logger.ts index be2807743..fb7cbda6d 100644 --- a/packages/logger/src/Logger.ts +++ b/packages/logger/src/Logger.ts @@ -3,12 +3,30 @@ import { ConsoleLogWriter, LogWriter } from './writers'; export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error'; +const logLevel: { [level in LogLevel]: number } = { + trace: 0, + debug: 1, + info: 2, + warn: 3, + error: 4, +}; + +// TODO: explain what happens when attribute keys match existing keys from the logger (like "msg") + +// TODO: an "id" or "name" of a logger allowing us to create scoped loggers which on their own can be disabled/enabled + export interface LoggerOptions { - /** A prefix to provide to every log's message. */ + /** + * The minimum log level to log. + * + * @default trace + */ + level?: LogLevel; + /** A prefix to include in every log's message. */ prefix?: string; /** * The attributes to include in all logs. Is mainly used to pass the parent - * attributes when creating child loggers. + * attributes when creating {@link Logger.child child loggers}. */ attrs?: Attributes; /** @@ -20,7 +38,8 @@ export interface LoggerOptions { } export class Logger implements LogWriter { - prefix: string | undefined; + #level: LogLevel; + #prefix: string | undefined; #attrs: Attributes | undefined; #writers: [LogWriter, ...LogWriter[]]; #pendingWrites = new Set>(); @@ -28,7 +47,8 @@ export class Logger implements LogWriter { // TODO: logs for specific level constructor(opts: LoggerOptions = { writers: [new ConsoleLogWriter()] }) { - this.prefix = opts.prefix; + this.#level = opts.level || 'trace'; + this.#prefix = opts.prefix; this.#attrs = opts.attrs; this.#writers = opts.writers; } @@ -65,17 +85,16 @@ export class Logger implements LogWriter { // public child(prefix: string): Logger; - public child(attrs: Attributes): Logger; + public child(attrs: Attributes, prefix?: string): Logger; public child(prefixOrAttrs: string | Attributes, prefix?: string): Logger { if (typeof prefixOrAttrs === 'string') { - prefix = prefixOrAttrs; return new Logger({ - prefix: prefix, + prefix: prefixOrAttrs, writers: this.#writers, }); } return new Logger({ - prefix: prefix, + prefix, attrs: prefixOrAttrs, writers: this.#writers, }); @@ -101,7 +120,9 @@ export class Logger implements LogWriter { ): void { // TODO: validate types on runtime, or not? - // TODO: log only if level is enabled + if (logLevel[level] < logLevel[this.#level]) { + return; + } let msg = ''; let attrs: Attributes | undefined; @@ -112,8 +133,8 @@ export class Logger implements LogWriter { msg = attrsOrMsg; } - if (this.prefix) { - msg = `${this.prefix.trim()} ${msg}`.trim(); // we trim everything because maybe the "msg" is empty + if (this.#prefix) { + msg = `${this.#prefix.trim()} ${msg}`.trim(); // we trim everything because maybe the "msg" is empty } // TODO: unwrap lazy attribute values diff --git a/packages/logger/src/utils.ts b/packages/logger/src/utils.ts index bf385ea3c..9a1a876e6 100644 --- a/packages/logger/src/utils.ts +++ b/packages/logger/src/utils.ts @@ -4,14 +4,14 @@ export type AttributeValue = | string | number | boolean - | { [key: PropertyKey]: AttributeValue } + | { [key: string | number]: AttributeValue } | AttributeValue[] | Object // redundant, but this will allow _any_ object be the value | null | undefined | (() => AttributeValue); // lazy attribute -export type Attributes = Record; +export type Attributes = Record; export function logLevelToString(level: LogLevel): string { switch (level) { From 23cd514d3c689a30ca503d8c656b142e6c368db5 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 3 Apr 2025 16:14:03 +0200 Subject: [PATCH 023/157] test level --- packages/logger/tests/Logger.test.ts | 38 +++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/packages/logger/tests/Logger.test.ts b/packages/logger/tests/Logger.test.ts index f819b51c8..1b649a9bc 100644 --- a/packages/logger/tests/Logger.test.ts +++ b/packages/logger/tests/Logger.test.ts @@ -1,5 +1,5 @@ import { expect, it } from 'vitest'; -import { Logger, LogLevel } from '../src/Logger'; +import { Logger, LoggerOptions, LogLevel } from '../src/Logger'; import { LogWriter } from '../src/writers'; class TLogWriter implements LogWriter { @@ -14,9 +14,12 @@ class TLogWriter implements LogWriter { } } -function createTLogger() { +function createTLogger(opts?: Partial) { const writer = new TLogWriter(); - return [new Logger({ writers: [writer] }), writer] as const; + return [ + new Logger({ ...opts, writers: opts?.writers ? opts.writers : [writer] }), + writer, + ] as const; } it('should write logs with levels, message and attributes', () => { @@ -46,3 +49,32 @@ it('should write logs with levels, message and attributes', () => { ] `); }); + +it('should write logs only if level is higher than set', () => { + const [log, writter] = createTLogger({ + level: 'info', + }); + + log.trace('Trace'); + log.debug('Debug'); + log.info('Info'); + log.warn('Warn'); + log.error('Error'); + + expect(writter.logs).toMatchInlineSnapshot(` + [ + { + "level": "info", + "msg": "Info", + }, + { + "level": "warn", + "msg": "Warn", + }, + { + "level": "error", + "msg": "Error", + }, + ] + `); +}); From 4e7994f09d900c15b101bd42fbe62dfc94200dd9 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 3 Apr 2025 16:16:21 +0200 Subject: [PATCH 024/157] test child --- packages/logger/tests/Logger.test.ts | 63 ++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/packages/logger/tests/Logger.test.ts b/packages/logger/tests/Logger.test.ts index 1b649a9bc..eb229858f 100644 --- a/packages/logger/tests/Logger.test.ts +++ b/packages/logger/tests/Logger.test.ts @@ -78,3 +78,66 @@ it('should write logs only if level is higher than set', () => { ] `); }); + +it('should include attributes in child loggers', () => { + let [log, writter] = createTLogger({ + level: 'info', + }); + + log = log.child({ par: 'ent' }); + + log.info('hello'); + + expect(writter.logs).toMatchInlineSnapshot(` + [ + { + "attrs": { + "par": "ent", + }, + "level": "info", + "msg": "hello", + }, + ] + `); +}); + +it('should include prefix in child loggers', () => { + let [log, writter] = createTLogger({ + level: 'info', + }); + + log = log.child('prefix'); + + log.info('hello'); + + expect(writter.logs).toMatchInlineSnapshot(` + [ + { + "level": "info", + "msg": "prefix hello", + }, + ] + `); +}); + +it('should include attributes and prefix in child loggers', () => { + let [log, writter] = createTLogger({ + level: 'info', + }); + + log = log.child({ par: 'ent' }, 'prefix'); + + log.info('hello'); + + expect(writter.logs).toMatchInlineSnapshot(` + [ + { + "attrs": { + "par": "ent", + }, + "level": "info", + "msg": "prefix hello", + }, + ] + `); +}); From 4fc9a4aefd84e4a05c4d8e1c571528b421c5e16b Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 3 Apr 2025 16:17:38 +0200 Subject: [PATCH 025/157] refactor --- packages/logger/tests/Logger.test.ts | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/packages/logger/tests/Logger.test.ts b/packages/logger/tests/Logger.test.ts index eb229858f..0b988d09a 100644 --- a/packages/logger/tests/Logger.test.ts +++ b/packages/logger/tests/Logger.test.ts @@ -23,14 +23,10 @@ function createTLogger(opts?: Partial) { } it('should write logs with levels, message and attributes', () => { - const [logger, writter] = createTLogger(); + const [log, writter] = createTLogger(); - logger.log( - 'info', - { hello: 'world', err: new Error('Woah!') }, - 'Hello, world!', - ); - logger.log('info', '2nd Hello, world!'); + log.log('info', { hello: 'world', err: new Error('Woah!') }, 'Hello, world!'); + log.log('info', '2nd Hello, world!'); expect(writter.logs).toMatchInlineSnapshot(` [ @@ -80,9 +76,7 @@ it('should write logs only if level is higher than set', () => { }); it('should include attributes in child loggers', () => { - let [log, writter] = createTLogger({ - level: 'info', - }); + let [log, writter] = createTLogger(); log = log.child({ par: 'ent' }); @@ -102,9 +96,7 @@ it('should include attributes in child loggers', () => { }); it('should include prefix in child loggers', () => { - let [log, writter] = createTLogger({ - level: 'info', - }); + let [log, writter] = createTLogger(); log = log.child('prefix'); @@ -121,9 +113,7 @@ it('should include prefix in child loggers', () => { }); it('should include attributes and prefix in child loggers', () => { - let [log, writter] = createTLogger({ - level: 'info', - }); + let [log, writter] = createTLogger(); log = log.child({ par: 'ent' }, 'prefix'); From 856798699d421da59f34b331e8cba4d2a4430835 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 3 Apr 2025 16:46:43 +0200 Subject: [PATCH 026/157] unwrap lazy attrs --- packages/logger/src/Logger.ts | 5 ++- packages/logger/src/utils.ts | 65 +++++++++++++++++++++++++++- packages/logger/tests/Logger.test.ts | 54 +++++++++++++++++++++++ 3 files changed, 121 insertions(+), 3 deletions(-) diff --git a/packages/logger/src/Logger.ts b/packages/logger/src/Logger.ts index fb7cbda6d..6c53d2939 100644 --- a/packages/logger/src/Logger.ts +++ b/packages/logger/src/Logger.ts @@ -1,4 +1,4 @@ -import { Attributes, isPromise } from './utils'; +import { Attributes, isPromise, unwrapAttrs } from './utils'; import { ConsoleLogWriter, LogWriter } from './writers'; export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error'; @@ -137,7 +137,8 @@ export class Logger implements LogWriter { msg = `${this.#prefix.trim()} ${msg}`.trim(); // we trim everything because maybe the "msg" is empty } - // TODO: unwrap lazy attribute values + attrs = this.#attrs ? { ...this.#attrs, ...attrs } : attrs; + attrs = attrs ? unwrapAttrs(attrs) : attrs; // @ts-expect-error TODO: interpolate values into the message const interpolationValues = rest; diff --git a/packages/logger/src/utils.ts b/packages/logger/src/utils.ts index 9a1a876e6..ab9671532 100644 --- a/packages/logger/src/utils.ts +++ b/packages/logger/src/utils.ts @@ -11,7 +11,9 @@ export type AttributeValue = | undefined | (() => AttributeValue); // lazy attribute -export type Attributes = Record; +export type Attributes = + | (() => Attributes) + | { [key: string | number]: AttributeValue }; export function logLevelToString(level: LogLevel): string { switch (level) { @@ -53,3 +55,64 @@ export function jsonStringify(val: unknown) { return val; }); } + +/** Recursivelly unwrapps the lazy attributes. */ +export function unwrapAttrs(attrs: Attributes, depth = 0): Attributes { + if (depth > 10) { + throw new Error('Too much recursion while unwrapping function attributes'); + } + + if (typeof attrs === 'function') { + return unwrapAttrs(attrs(), depth + 1); + } + + const unwrapped: Attributes = {}; + for (const key of Object.keys(attrs)) { + const val = attrs[key as keyof typeof attrs]; + unwrapped[key] = unwrapAttrVal(val, depth + 1); + } + return unwrapped; +} + +function unwrapAttrVal(attr: AttributeValue, depth = 0): AttributeValue { + if (depth > 10) { + throw new Error( + 'Too much recursion while unwrapping function attribute values', + ); + } + + if (!attr) { + return attr; + } + + if (isPrimitive(attr)) { + return attr; + } + + if (typeof attr === 'function') { + return unwrapAttrVal(attr(), depth + 1); + } + + // unwrap array items + if (Array.isArray(attr)) { + return attr.map((val) => unwrapAttrVal(val, depth + 1)); + } + + // plain object (not an instance of anything) + // NOTE: is valnurable to `Symbol.toStringTag` pollution, but the user would be sabotaging themselves + if (Object.prototype.toString.call(attr) === '[object Object]') { + const unwrapped: { [key: string | number]: AttributeValue } = {}; + for (const key of Object.keys(attr)) { + const val = attr[key as keyof typeof attr]; + unwrapped[key] = unwrapAttrVal(val, depth + 1); + } + return unwrapped; + } + + // very likely an instance of something, dont unwrap it + return attr; +} + +function isPrimitive(val: unknown): val is string | number | boolean { + return val !== Object(val); +} diff --git a/packages/logger/tests/Logger.test.ts b/packages/logger/tests/Logger.test.ts index 0b988d09a..e892ee43c 100644 --- a/packages/logger/tests/Logger.test.ts +++ b/packages/logger/tests/Logger.test.ts @@ -131,3 +131,57 @@ it('should include attributes and prefix in child loggers', () => { ] `); }); + +it('should unwrap lazy attributes', () => { + const [log, writter] = createTLogger(); + + log.info( + { + lazy: () => 'lazy', + nested: { + lazy: () => 'nested lazy', + }, + arr: [() => '0', '1'], + }, + 'hello', + ); + + log.info( + () => ({ + every: 'thing', + nested: { + lazy: () => 'nested lazy', + }, + }), + 'hello', + ); + + expect(writter.logs).toMatchInlineSnapshot(` + [ + { + "attrs": { + "arr": [ + "0", + "1", + ], + "lazy": "lazy", + "nested": { + "lazy": "nested lazy", + }, + }, + "level": "info", + "msg": "hello", + }, + { + "attrs": { + "every": "thing", + "nested": { + "lazy": "nested lazy", + }, + }, + "level": "info", + "msg": "hello", + }, + ] + `); +}); From 2142191458d458ce9a89adfc2f66e470493306ae Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 3 Apr 2025 16:48:37 +0200 Subject: [PATCH 027/157] no unwrap if no log --- packages/logger/tests/Logger.test.ts | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/logger/tests/Logger.test.ts b/packages/logger/tests/Logger.test.ts index e892ee43c..b6e200443 100644 --- a/packages/logger/tests/Logger.test.ts +++ b/packages/logger/tests/Logger.test.ts @@ -1,4 +1,4 @@ -import { expect, it } from 'vitest'; +import { expect, it, vi } from 'vitest'; import { Logger, LoggerOptions, LogLevel } from '../src/Logger'; import { LogWriter } from '../src/writers'; @@ -185,3 +185,25 @@ it('should unwrap lazy attributes', () => { ] `); }); + +it('should not unwrap lazy attributes if level is not to be logged', () => { + const [log] = createTLogger({ + level: 'info', + }); + + const lazy = vi.fn(() => ({ la: 'zy' })); + log.debug( + { + lazy, + nested: { + lazy, + }, + arr: [lazy, '1'], + }, + 'hello', + ); + + log.debug(lazy, 'hello'); + + expect(lazy).not.toHaveBeenCalled(); +}); From d78d7b60c16a18b2033e541e7d8a28234e400a19 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 3 Apr 2025 16:50:35 +0200 Subject: [PATCH 028/157] todos --- packages/logger/tests/Logger.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/logger/tests/Logger.test.ts b/packages/logger/tests/Logger.test.ts index b6e200443..a164b4788 100644 --- a/packages/logger/tests/Logger.test.ts +++ b/packages/logger/tests/Logger.test.ts @@ -207,3 +207,7 @@ it('should not unwrap lazy attributes if level is not to be logged', () => { expect(lazy).not.toHaveBeenCalled(); }); + +it.todo('should log to async writers'); + +it.todo('should wait for async writers on flush'); From f62a23c564fa780744f13b6bef82376402ecb06e Mon Sep 17 00:00:00 2001 From: enisdenjo Date: Thu, 3 Apr 2025 20:21:35 +0200 Subject: [PATCH 029/157] refactor logger --- packages/logger/src/Logger.ts | 149 ++++++++++++++++++--------------- packages/logger/src/utils.ts | 1 + packages/logger/src/writers.ts | 10 ++- tsconfig.json | 1 + 4 files changed, 92 insertions(+), 69 deletions(-) diff --git a/packages/logger/src/Logger.ts b/packages/logger/src/Logger.ts index 6c53d2939..2ec3bc6be 100644 --- a/packages/logger/src/Logger.ts +++ b/packages/logger/src/Logger.ts @@ -19,9 +19,11 @@ export interface LoggerOptions { /** * The minimum log level to log. * + * Providing `false` will disable all logging. + * * @default trace */ - level?: LogLevel; + level?: LogLevel | false; /** A prefix to include in every log's message. */ prefix?: string; /** @@ -34,11 +36,11 @@ export interface LoggerOptions { * * @default [new ConsoleLogWriter()] */ - writers: [LogWriter, ...LogWriter[]]; + writers?: [LogWriter, ...LogWriter[]]; } export class Logger implements LogWriter { - #level: LogLevel; + #level: LogLevel | false; #prefix: string | undefined; #attrs: Attributes | undefined; #writers: [LogWriter, ...LogWriter[]]; @@ -46,20 +48,24 @@ export class Logger implements LogWriter { // TODO: logs for specific level - constructor(opts: LoggerOptions = { writers: [new ConsoleLogWriter()] }) { - this.#level = opts.level || 'trace'; + constructor(opts: LoggerOptions = {}) { + this.#level = opts.level ?? 'trace'; this.#prefix = opts.prefix; this.#attrs = opts.attrs; - this.#writers = opts.writers; + this.#writers = opts.writers ?? [new ConsoleLogWriter()]; + } + + public get prefix() { + return this.#prefix; } public write( level: LogLevel, - msg: string, - attrs: Attributes | undefined, + attrs: Attributes | null | undefined, + msg: string | null | undefined, ): void { const pendingWrites = this.#writers - .map((writer) => writer.write(level, msg, attrs)) + .map((writer) => writer.write(level, attrs, msg)) .filter(isPromise); for (const pendingWrite of pendingWrites) { @@ -102,97 +108,108 @@ export class Logger implements LogWriter { // + public log(level: LogLevel): void; + public log(level: LogLevel, attrs: Attributes): void; + public log(level: LogLevel, msg: string, ...interpol: unknown[]): void; public log( level: LogLevel, attrs: Attributes, msg: string, - ...interpolationValues: unknown[] - ): void; - public log( - level: LogLevel, - msg: string, - ...interpolationValues: unknown[] + ...interpol: unknown[] ): void; public log( level: LogLevel, - attrsOrMsg: Attributes | string, + maybeAttrsOrMsg?: Attributes | string | null | undefined, ...rest: unknown[] ): void { // TODO: validate types on runtime, or not? - if (logLevel[level] < logLevel[this.#level]) { + if (this.#level === false || logLevel[level] < logLevel[this.#level]) { return; } - let msg = ''; + let msg: string | null = null; let attrs: Attributes | undefined; - if (attrsOrMsg instanceof Object) { - attrs = attrsOrMsg; - msg = rest.shift() + ''; // as per the overload, the first rest value is the message. TODO: enforce in runtime? - } else { - msg = attrsOrMsg; + if (typeof maybeAttrsOrMsg === 'string') { + msg = maybeAttrsOrMsg; + } else if (maybeAttrsOrMsg) { + attrs = maybeAttrsOrMsg; + if (typeof rest[0] === 'string') { + // we shift because the "rest" becomes "interpol" + msg = rest.shift() as string; + } } if (this.#prefix) { - msg = `${this.#prefix.trim()} ${msg}`.trim(); // we trim everything because maybe the "msg" is empty + msg = `${this.#prefix.trim()} ${msg || ''}`.trim(); // we trim everything because maybe the "msg" is empty } attrs = this.#attrs ? { ...this.#attrs, ...attrs } : attrs; attrs = attrs ? unwrapAttrs(attrs) : attrs; // @ts-expect-error TODO: interpolate values into the message - const interpolationValues = rest; + const interpol = rest; - this.write(level, msg, this.#attrs ? { ...this.#attrs, ...attrs } : attrs); + this.write(level, attrs, msg); } - public trace( - attrs: Attributes, - msg: string, - ...interpolationValues: unknown[] - ): void; - public trace(msg: string, ...interpolationValues: unknown[]): void; - public trace(...args: [arg0: any, ...rest: any[]]): void { - this.log('trace', ...args); + public trace(): void; + public trace(attrs: Attributes): void; + public trace(msg: string, ...interpol: unknown[]): void; + public trace(attrs: Attributes, msg: string, ...interpol: unknown[]): void; + public trace(...args: any): void { + this.log( + 'trace', + // @ts-expect-error + ...args, + ); } - public debug( - attrs: Attributes, - msg: string, - ...interpolationValues: unknown[] - ): void; - public debug(msg: string, ...interpolationValues: unknown[]): void; - public debug(...args: [arg0: any, ...rest: any[]]): void { - this.log('debug', ...args); + public debug(): void; + public debug(attrs: Attributes): void; + public debug(msg: string, ...interpol: unknown[]): void; + public debug(attrs: Attributes, msg: string, ...interpol: unknown[]): void; + public debug(...args: any): void { + this.log( + 'debug', + // @ts-expect-error + ...args, + ); } - public info( - attrs: Attributes, - msg: string, - ...interpolationValues: unknown[] - ): void; - public info(msg: string, ...interpolationValues: unknown[]): void; - public info(...args: [arg0: any, ...rest: any[]]): void { - this.log('info', ...args); + public info(): void; + public info(attrs: Attributes): void; + public info(msg: string, ...interpol: unknown[]): void; + public info(attrs: Attributes, msg: string, ...interpol: unknown[]): void; + public info(...args: any): void { + this.log( + 'info', + // @ts-expect-error + ...args, + ); } - public warn( - attrs: Attributes, - msg: string, - ...interpolationValues: unknown[] - ): void; - public warn(msg: string, ...interpolationValues: unknown[]): void; - public warn(...args: [arg0: any, ...rest: any[]]): void { - this.log('warn', ...args); + public warn(): void; + public warn(attrs: Attributes): void; + public warn(msg: string, ...interpol: unknown[]): void; + public warn(attrs: Attributes, msg: string, ...interpol: unknown[]): void; + public warn(...args: any): void { + this.log( + 'warn', + // @ts-expect-error + ...args, + ); } - public error( - attrs: Attributes, - msg: string, - ...interpolationValues: unknown[] - ): void; - public error(msg: string, ...interpolationValues: unknown[]): void; - public error(...args: [arg0: any, ...rest: any[]]): void { - this.log('error', ...args); + public error(): void; + public error(attrs: Attributes): void; + public error(msg: string, ...interpol: unknown[]): void; + public error(attrs: Attributes, msg: string, ...interpol: unknown[]): void; + public error(...args: any): void { + this.log( + 'error', + // @ts-expect-error + ...args, + ); } } diff --git a/packages/logger/src/utils.ts b/packages/logger/src/utils.ts index ab9671532..96678b989 100644 --- a/packages/logger/src/utils.ts +++ b/packages/logger/src/utils.ts @@ -13,6 +13,7 @@ export type AttributeValue = export type Attributes = | (() => Attributes) + | AttributeValue[] | { [key: string | number]: AttributeValue }; export function logLevelToString(level: LogLevel): string { diff --git a/packages/logger/src/writers.ts b/packages/logger/src/writers.ts index 1ab1fde97..aa46cc85b 100644 --- a/packages/logger/src/writers.ts +++ b/packages/logger/src/writers.ts @@ -4,14 +4,18 @@ import { Attributes, jsonStringify } from './utils'; export interface LogWriter { write( level: LogLevel, - msg: string, - attrs: Attributes | undefined, + attrs: Attributes | null | undefined, + msg: string | null | undefined, ): void | Promise; flush(): void | Promise; } export class ConsoleLogWriter implements LogWriter { - write(level: LogLevel, msg: string, attrs: Attributes): void { + write( + level: LogLevel, + attrs: Attributes | null | undefined, + msg: string | null | undefined, + ): void { switch (level) { // TODO: other levels default: diff --git a/tsconfig.json b/tsconfig.json index 583838cbd..15642b6da 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -61,6 +61,7 @@ ], "@graphql-tools/wrap": ["./packages/wrap/src/index.ts"], "@graphql-tools/executor-*": ["./packages/executors/*/src/index.ts"], + "@graphql-hive/logger": ["./packages/logger/src/index.ts"], "@graphql-hive/logger-json": ["./packages/logger-json/src/index.ts"], "@graphql-hive/logger-winston": [ "./packages/logger-winston/src/index.ts" From da9bf492f8060f856b940f4681236db3fee465da Mon Sep 17 00:00:00 2001 From: enisdenjo Date: Thu, 3 Apr 2025 22:00:43 +0200 Subject: [PATCH 030/157] log and prepare --- packages/logger/package.json | 4 ++- packages/logger/src/Logger.ts | 23 ++++++------- packages/logger/src/utils.ts | 18 +++++++++++ packages/logger/tests/Logger.test.ts | 48 ++++++++++++++++++++-------- yarn.lock | 11 ++++++- 5 files changed, 77 insertions(+), 27 deletions(-) diff --git a/packages/logger/package.json b/packages/logger/package.json index 8fc2bf2ed..c87511238 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -39,7 +39,9 @@ "prepack": "yarn build" }, "devDependencies": { - "pkgroll": "2.11.2" + "@types/quick-format-unescaped": "^4.0.3", + "pkgroll": "2.11.2", + "quick-format-unescaped": "^4.0.4" }, "sideEffects": false } diff --git a/packages/logger/src/Logger.ts b/packages/logger/src/Logger.ts index 2ec3bc6be..bf4545173 100644 --- a/packages/logger/src/Logger.ts +++ b/packages/logger/src/Logger.ts @@ -1,4 +1,5 @@ -import { Attributes, isPromise, unwrapAttrs } from './utils'; +import format from 'quick-format-unescaped'; +import { Attributes, getEnv, isPromise, unwrapAttrs } from './utils'; import { ConsoleLogWriter, LogWriter } from './writers'; export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error'; @@ -21,7 +22,7 @@ export interface LoggerOptions { * * Providing `false` will disable all logging. * - * @default trace + * @default env.LOG_LEVEL || 'trace' */ level?: LogLevel | false; /** A prefix to include in every log's message. */ @@ -46,10 +47,14 @@ export class Logger implements LogWriter { #writers: [LogWriter, ...LogWriter[]]; #pendingWrites = new Set>(); - // TODO: logs for specific level - constructor(opts: LoggerOptions = {}) { - this.#level = opts.level ?? 'trace'; + let logLevelEnv = getEnv('LOG_LEVEL'); + if (logLevelEnv && !(logLevelEnv in logLevel)) { + throw new Error( + `Invalid LOG_LEVEL environment variable "${logLevelEnv}". Must be one of: ${[...Object.keys(logLevel), 'false'].join(', ')}`, + ); + } + this.#level = opts.level ?? (logLevelEnv as LogLevel) ?? 'trace'; this.#prefix = opts.prefix; this.#attrs = opts.attrs; this.#writers = opts.writers ?? [new ConsoleLogWriter()]; @@ -122,13 +127,11 @@ export class Logger implements LogWriter { maybeAttrsOrMsg?: Attributes | string | null | undefined, ...rest: unknown[] ): void { - // TODO: validate types on runtime, or not? - if (this.#level === false || logLevel[level] < logLevel[this.#level]) { return; } - let msg: string | null = null; + let msg: string | undefined; let attrs: Attributes | undefined; if (typeof maybeAttrsOrMsg === 'string') { msg = maybeAttrsOrMsg; @@ -146,9 +149,7 @@ export class Logger implements LogWriter { attrs = this.#attrs ? { ...this.#attrs, ...attrs } : attrs; attrs = attrs ? unwrapAttrs(attrs) : attrs; - - // @ts-expect-error TODO: interpolate values into the message - const interpol = rest; + msg = msg ? format(msg, rest) : msg; this.write(level, attrs, msg); } diff --git a/packages/logger/src/utils.ts b/packages/logger/src/utils.ts index 96678b989..63271a706 100644 --- a/packages/logger/src/utils.ts +++ b/packages/logger/src/utils.ts @@ -117,3 +117,21 @@ function unwrapAttrVal(attr: AttributeValue, depth = 0): AttributeValue { function isPrimitive(val: unknown): val is string | number | boolean { return val !== Object(val); } + +export function getEnv(key: string): string | undefined { + return ( + globalThis.process?.env?.[key] || + // @ts-expect-error can exist in wrangler and maybe other runtimes + globalThis.env?.[key] || + // @ts-expect-error can exist in deno + globalThis.Deno?.env?.get(key) || + // @ts-expect-error could be + globalThis[key] + ); +} + +export function truthyEnv(key: string): boolean { + return ['1', 't', 'true', 'y', 'yes'].includes( + getEnv(key)?.toLowerCase() || '', + ); +} diff --git a/packages/logger/tests/Logger.test.ts b/packages/logger/tests/Logger.test.ts index a164b4788..c041d4097 100644 --- a/packages/logger/tests/Logger.test.ts +++ b/packages/logger/tests/Logger.test.ts @@ -1,21 +1,12 @@ import { expect, it, vi } from 'vitest'; -import { Logger, LoggerOptions, LogLevel } from '../src/Logger'; -import { LogWriter } from '../src/writers'; +import { Logger, LoggerOptions } from '../src/Logger'; +import { MemoryLogWriter } from '../src/writers'; -class TLogWriter implements LogWriter { - public logs: { level: LogLevel; msg: string; attrs?: unknown }[] = []; - - write(level: LogLevel, msg: string, attrs: Record): void { - this.logs.push({ level, msg, ...(attrs ? { attrs } : {}) }); - } - - flush(): void { - // noop - } -} +const log = new Logger(); +log.info('Hello, world!'); function createTLogger(opts?: Partial) { - const writer = new TLogWriter(); + const writer = new MemoryLogWriter(); return [ new Logger({ ...opts, writers: opts?.writers ? opts.writers : [writer] }), writer, @@ -25,11 +16,15 @@ function createTLogger(opts?: Partial) { it('should write logs with levels, message and attributes', () => { const [log, writter] = createTLogger(); + log.log('info'); log.log('info', { hello: 'world', err: new Error('Woah!') }, 'Hello, world!'); log.log('info', '2nd Hello, world!'); expect(writter.logs).toMatchInlineSnapshot(` [ + { + "level": "info", + }, { "attrs": { "err": [Error: Woah!], @@ -211,3 +206,28 @@ it('should not unwrap lazy attributes if level is not to be logged', () => { it.todo('should log to async writers'); it.todo('should wait for async writers on flush'); + +it('should format string', () => { + const [log, writer] = createTLogger(); + + log.info('%o hello %s', { worldly: 1 }, 'world'); + log.info({ these: { are: 'attrs' } }, '%o hello %s', { worldly: 1 }, 'world'); + + expect(writer.logs).toMatchInlineSnapshot(` + [ + { + "level": "info", + "msg": "{"worldly":1} hello world", + }, + { + "attrs": { + "these": { + "are": "attrs", + }, + }, + "level": "info", + "msg": "{"worldly":1} hello world", + }, + ] + `); +}); diff --git a/yarn.lock b/yarn.lock index d886ae0ea..226253f60 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4126,7 +4126,9 @@ __metadata: version: 0.0.0-use.local resolution: "@graphql-hive/logger@workspace:packages/logger" dependencies: + "@types/quick-format-unescaped": "npm:^4.0.3" pkgroll: "npm:2.11.2" + quick-format-unescaped: "npm:^4.0.4" languageName: unknown linkType: soft @@ -8562,6 +8564,13 @@ __metadata: languageName: node linkType: hard +"@types/quick-format-unescaped@npm:^4.0.3": + version: 4.0.3 + resolution: "@types/quick-format-unescaped@npm:4.0.3" + checksum: 10c0/e95ba1dfa68f9d1dee785905c3c648b9fe1514c5261160e566d9a19731013d138269937632dea79a73859988a90621c9653504170c6a7415cacb426343f2a11a + languageName: node + linkType: hard + "@types/range-parser@npm:*": version: 1.2.7 resolution: "@types/range-parser@npm:1.2.7" @@ -18279,7 +18288,7 @@ __metadata: languageName: node linkType: hard -"quick-format-unescaped@npm:^4.0.3": +"quick-format-unescaped@npm:^4.0.3, quick-format-unescaped@npm:^4.0.4": version: 4.0.4 resolution: "quick-format-unescaped@npm:4.0.4" checksum: 10c0/fe5acc6f775b172ca5b4373df26f7e4fd347975578199e7d74b2ae4077f0af05baa27d231de1e80e8f72d88275ccc6028568a7a8c9ee5e7368ace0e18eff93a4 From b2579b5c94bd3d0ea85e5eff7e39aa6346e1b8bd Mon Sep 17 00:00:00 2001 From: enisdenjo Date: Fri, 4 Apr 2025 00:08:08 +0200 Subject: [PATCH 031/157] safe stringify --- packages/logger/package.json | 1 + packages/logger/src/utils.ts | 29 ++++++----- packages/logger/src/writers.ts | 88 +++++++++++++++++++++++++++++----- yarn.lock | 1 + 4 files changed, 96 insertions(+), 23 deletions(-) diff --git a/packages/logger/package.json b/packages/logger/package.json index c87511238..ebcff3dab 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -40,6 +40,7 @@ }, "devDependencies": { "@types/quick-format-unescaped": "^4.0.3", + "fast-safe-stringify": "^2.1.1", "pkgroll": "2.11.2", "quick-format-unescaped": "^4.0.4" }, diff --git a/packages/logger/src/utils.ts b/packages/logger/src/utils.ts index 63271a706..d78fbbd38 100644 --- a/packages/logger/src/utils.ts +++ b/packages/logger/src/utils.ts @@ -1,3 +1,4 @@ +import fastSafeStringify from 'fast-safe-stringify'; import { LogLevel } from './Logger'; export type AttributeValue = @@ -43,18 +44,22 @@ export function isPromise(val: unknown): val is Promise { } /** An error safe JSON stringifyer. */ -export function jsonStringify(val: unknown) { - return JSON.stringify(val, (_key, val) => { - if (val instanceof Error) { - // TODO: also handle graphql errors, and maybe all other errors that can contain more properties - return { - name: val.name, - message: val.message, - stack: val.stack, - }; - } - return val; - }); +export function jsonStringify(val: unknown, pretty?: boolean): string { + return fastSafeStringify( + val, + (_key, val) => { + if (val instanceof Error) { + // TODO: also handle graphql errors, and maybe all other errors that can contain more properties + return { + name: val.name, + message: val.message, + stack: val.stack, + }; + } + return val; + }, + pretty ? 2 : undefined, + ); } /** Recursivelly unwrapps the lazy attributes. */ diff --git a/packages/logger/src/writers.ts b/packages/logger/src/writers.ts index aa46cc85b..12b84ac21 100644 --- a/packages/logger/src/writers.ts +++ b/packages/logger/src/writers.ts @@ -1,5 +1,10 @@ import { LogLevel } from './Logger'; -import { Attributes, jsonStringify } from './utils'; +import { + Attributes, + jsonStringify, + logLevelToString, + truthyEnv, +} from './utils'; export interface LogWriter { write( @@ -10,22 +15,83 @@ export interface LogWriter { flush(): void | Promise; } +export class MemoryLogWriter implements LogWriter { + public logs: { level: LogLevel; msg?: string; attrs?: unknown }[] = []; + write( + level: LogLevel, + attrs: Record, + msg: string | null | undefined, + ): void { + this.logs.push({ + level, + ...(msg ? { msg } : {}), + ...(attrs ? { attrs } : {}), + }); + } + flush(): void { + // noop + } +} + +const asciMap = { + timestamp: '\x1b[90m', // bright black + trace: '\x1b[36m', // cyan + debug: '\x1b[90m', // bright black + info: '\x1b[32m', // green + warn: '\x1b[33m', // yellow + error: '\x1b[41;39m', // red; white + message: '\x1b[1m', // bold + reset: '\x1b[0m', // reset +}; + export class ConsoleLogWriter implements LogWriter { + #nocolor = truthyEnv('NO_COLOR'); + color(style: keyof typeof asciMap, text: string | null | undefined) { + if (!text) { + return text; + } + if (this.#nocolor) { + return text; + } + return asciMap[style] + text + asciMap.reset; + } write( level: LogLevel, attrs: Attributes | null | undefined, msg: string | null | undefined, ): void { - switch (level) { - // TODO: other levels - default: - // TODO: write log level and time - console.log( - msg, - // we want to stringify because we want all properties be properly displayed - attrs ? jsonStringify(attrs) : undefined, - ); - } + console[level === 'trace' ? 'debug' : level]( + [ + this.color('timestamp', new Date().toISOString()), + this.color(level, logLevelToString(level)), + this.color('message', msg), + // we want to stringify because we want all properties (even nested ones)be properly displayed + attrs ? jsonStringify(attrs, truthyEnv('LOG_JSON_PRETTY')) : undefined, + ].join(' '), + ); + } + flush() { + // noop + } +} + +export class JSONLogWriter implements LogWriter { + write( + level: LogLevel, + attrs: Attributes | null | undefined, + msg: string | null | undefined, + ): void { + console.log( + jsonStringify( + { + ...attrs, + level, + ...(msg ? { msg } : {}), + timestamp: new Date().toISOString(), + }, + truthyEnv('LOG_JSON_PRETTY'), + ), + ); } flush() { // noop diff --git a/yarn.lock b/yarn.lock index 226253f60..eeb85b32f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4127,6 +4127,7 @@ __metadata: resolution: "@graphql-hive/logger@workspace:packages/logger" dependencies: "@types/quick-format-unescaped": "npm:^4.0.3" + fast-safe-stringify: "npm:^2.1.1" pkgroll: "npm:2.11.2" quick-format-unescaped: "npm:^4.0.4" languageName: unknown From 09f59ea640e60c97b8c18a972813d419e79f6e71 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Mon, 7 Apr 2025 20:22:11 +0200 Subject: [PATCH 032/157] legacy logger --- packages/logger/src/LegacyLogger.ts | 92 +++++++++++++++++++++++++++++ packages/logger/src/Logger.ts | 31 ++++++---- packages/logger/src/utils.ts | 18 ++++++ 3 files changed, 129 insertions(+), 12 deletions(-) create mode 100644 packages/logger/src/LegacyLogger.ts diff --git a/packages/logger/src/LegacyLogger.ts b/packages/logger/src/LegacyLogger.ts new file mode 100644 index 000000000..3fbba96bf --- /dev/null +++ b/packages/logger/src/LegacyLogger.ts @@ -0,0 +1,92 @@ +import { Logger, LogLevel } from './Logger'; +import { shouldLog } from './utils'; + +// type comes from "@graphql-mesh/types" package, we're copying them over just to avoid including the whole package +export type LazyLoggerMessage = (() => any | any[]) | any; + +/** @deprecated Please migrate to using the {@link Logger} instead.*/ +export class LegacyLogger { + #logger: Logger; + + constructor(logger: Logger) { + this.#logger = logger; + } + + static from(logger: Logger): LegacyLogger { + return new LegacyLogger(logger); + } + + #log(level: LogLevel, ...[maybeMsgOrArg, ...restArgs]: any[]) { + if (typeof maybeMsgOrArg === 'string') { + this.#logger.log(level, restArgs, maybeMsgOrArg); + } else { + if (restArgs.length) { + this.#logger.log(level, [maybeMsgOrArg, ...restArgs]); + } else { + this.#logger.log(level, maybeMsgOrArg); + } + } + } + + log(...args: any[]) { + this.#log('info', ...args); + } + + warn(...args: any[]) { + this.#log('warn', ...args); + } + + info(...args: any[]) { + this.#log('info', ...args); + } + + error(...args: any[]) { + this.#log('error', ...args); + } + + debug(...lazyArgs: LazyLoggerMessage[]) { + if (!shouldLog(this.#logger.level, 'debug')) { + // we only return early here because only debug can have lazy logs + return; + } + this.#log('debug', ...handleLazyMessage(lazyArgs)); + } + + child(name: string | Record): LegacyLogger { + name = stringifyName(name); + if (this.#logger.prefix?.includes(name)) { + // TODO: why do we do this? + return this; + } + return LegacyLogger.from(this.#logger.child(name)); + } + + addPrefix(prefix: string | Record): LegacyLogger { + prefix = stringifyName(prefix); + if (this.#logger.prefix?.includes(prefix)) { + // TODO: why do we do this? + return this; + } + return LegacyLogger.from(this.#logger.child(prefix)); + } +} + +function stringifyName(name: string | Record) { + if (typeof name === 'string' || typeof name === 'number') { + return `${name}`; + } + const names: string[] = []; + for (const [key, value] of Object.entries(name)) { + names.push(`${key}=${value}`); + } + return `${names.join(', ')}`; +} + +function handleLazyMessage(lazyArgs: LazyLoggerMessage[]) { + return lazyArgs.flat(Infinity).flatMap((arg) => { + if (typeof arg === 'function') { + return arg(); + } + return arg; + }); +} diff --git a/packages/logger/src/Logger.ts b/packages/logger/src/Logger.ts index bf4545173..8524253b6 100644 --- a/packages/logger/src/Logger.ts +++ b/packages/logger/src/Logger.ts @@ -1,17 +1,17 @@ import format from 'quick-format-unescaped'; -import { Attributes, getEnv, isPromise, unwrapAttrs } from './utils'; +import { + Attributes, + getEnv, + isPromise, + logLevel, + shouldLog, + truthyEnv, + unwrapAttrs, +} from './utils'; import { ConsoleLogWriter, LogWriter } from './writers'; export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error'; -const logLevel: { [level in LogLevel]: number } = { - trace: 0, - debug: 1, - info: 2, - warn: 3, - error: 4, -}; - // TODO: explain what happens when attribute keys match existing keys from the logger (like "msg") // TODO: an "id" or "name" of a logger allowing us to create scoped loggers which on their own can be disabled/enabled @@ -22,7 +22,7 @@ export interface LoggerOptions { * * Providing `false` will disable all logging. * - * @default env.LOG_LEVEL || 'trace' + * @default env.LOG_LEVEL || env.DEBUG ? 'debug' : 'info' */ level?: LogLevel | false; /** A prefix to include in every log's message. */ @@ -54,7 +54,10 @@ export class Logger implements LogWriter { `Invalid LOG_LEVEL environment variable "${logLevelEnv}". Must be one of: ${[...Object.keys(logLevel), 'false'].join(', ')}`, ); } - this.#level = opts.level ?? (logLevelEnv as LogLevel) ?? 'trace'; + this.#level = + opts.level ?? + (logLevelEnv as LogLevel) ?? + (truthyEnv('DEBUG') ? 'debug' : 'info'); this.#prefix = opts.prefix; this.#attrs = opts.attrs; this.#writers = opts.writers ?? [new ConsoleLogWriter()]; @@ -64,6 +67,10 @@ export class Logger implements LogWriter { return this.#prefix; } + public get level() { + return this.#level; + } + public write( level: LogLevel, attrs: Attributes | null | undefined, @@ -127,7 +134,7 @@ export class Logger implements LogWriter { maybeAttrsOrMsg?: Attributes | string | null | undefined, ...rest: unknown[] ): void { - if (this.#level === false || logLevel[level] < logLevel[this.#level]) { + if (!shouldLog(this.#level, level)) { return; } diff --git a/packages/logger/src/utils.ts b/packages/logger/src/utils.ts index d78fbbd38..11556cbf9 100644 --- a/packages/logger/src/utils.ts +++ b/packages/logger/src/utils.ts @@ -17,6 +17,24 @@ export type Attributes = | AttributeValue[] | { [key: string | number]: AttributeValue }; +export const logLevel: { [level in LogLevel]: number } = { + trace: 0, + debug: 1, + info: 2, + warn: 3, + error: 4, +}; + +export function shouldLog( + setLevel: LogLevel | false, + loggingLevel: LogLevel, +): boolean { + return ( + setLevel !== false && // logging is not disabled + logLevel[setLevel] <= logLevel[loggingLevel] // and set log level is less than or equal to logging level + ); +} + export function logLevelToString(level: LogLevel): string { switch (level) { case 'trace': From 2f4654a4f476fbc5b091fe025c2773d25c8252f2 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Tue, 8 Apr 2025 15:13:48 +0200 Subject: [PATCH 033/157] refactor some --- packages/logger/tests/Logger.test.ts | 3 -- packages/runtime/src/plugins/useCacheDebug.ts | 36 +++++++++---------- .../src/plugins/useSubgraphExecuteDebug.ts | 36 +++++++++---------- 3 files changed, 33 insertions(+), 42 deletions(-) diff --git a/packages/logger/tests/Logger.test.ts b/packages/logger/tests/Logger.test.ts index c041d4097..273192bd1 100644 --- a/packages/logger/tests/Logger.test.ts +++ b/packages/logger/tests/Logger.test.ts @@ -2,9 +2,6 @@ import { expect, it, vi } from 'vitest'; import { Logger, LoggerOptions } from '../src/Logger'; import { MemoryLogWriter } from '../src/writers'; -const log = new Logger(); -log.info('Hello, world!'); - function createTLogger(opts?: Partial) { const writer = new MemoryLogWriter(); return [ diff --git a/packages/runtime/src/plugins/useCacheDebug.ts b/packages/runtime/src/plugins/useCacheDebug.ts index 480bf4da0..a07cdc7b2 100644 --- a/packages/runtime/src/plugins/useCacheDebug.ts +++ b/packages/runtime/src/plugins/useCacheDebug.ts @@ -1,48 +1,46 @@ -import { Logger } from '@graphql-mesh/types'; +import type { Logger } from '@graphql-hive/logger'; import { GatewayPlugin } from '../types'; -export function useCacheDebug>(opts: { - logger: Logger; -}): GatewayPlugin { +export function useCacheDebug< + TContext extends Record, +>(): GatewayPlugin { return { onCacheGet({ key }) { + log = log.child({ key }); + log.debug('cache get'); return { onCacheGetError({ error }) { - const cacheGetErrorLogger = opts.logger.child('cache-get-error'); - cacheGetErrorLogger.error({ key, error }); + log.error({ key, error }, 'error'); }, onCacheHit({ value }) { - const cacheHitLogger = opts.logger.child('cache-hit'); - cacheHitLogger.debug({ key, value }); + log.debug({ key, value }, 'hit'); }, onCacheMiss() { - const cacheMissLogger = opts.logger.child('cache-miss'); - cacheMissLogger.debug({ key }); + log.debug({ key }, 'miss'); }, }; }, onCacheSet({ key, value, ttl }) { + log = log.child({ key, value, ttl }); + log.debug('cache set'); return { onCacheSetError({ error }) { - const cacheSetErrorLogger = opts.logger.child('cache-set-error'); - cacheSetErrorLogger.error({ key, value, ttl, error }); + log.error({ error }, 'error'); }, onCacheSetDone() { - const cacheSetDoneLogger = opts.logger.child('cache-set-done'); - cacheSetDoneLogger.debug({ key, value, ttl }); + log.debug('done'); }, }; }, onCacheDelete({ key }) { + log = log.child({ key }); + log.debug('cache delete'); return { onCacheDeleteError({ error }) { - const cacheDeleteErrorLogger = - opts.logger.child('cache-delete-error'); - cacheDeleteErrorLogger.error({ key, error }); + log.error({ error }, 'error'); }, onCacheDeleteDone() { - const cacheDeleteDoneLogger = opts.logger.child('cache-delete-done'); - cacheDeleteDoneLogger.debug({ key }); + log.debug('done'); }, }; }, diff --git a/packages/runtime/src/plugins/useSubgraphExecuteDebug.ts b/packages/runtime/src/plugins/useSubgraphExecuteDebug.ts index d009a1e2b..88139aa50 100644 --- a/packages/runtime/src/plugins/useSubgraphExecuteDebug.ts +++ b/packages/runtime/src/plugins/useSubgraphExecuteDebug.ts @@ -1,24 +1,23 @@ import { defaultPrintFn } from '@graphql-mesh/transport-common'; -import type { Logger } from '@graphql-mesh/types'; import { FetchAPI, isAsyncIterable } from 'graphql-yoga'; import type { GatewayPlugin } from '../types'; export function useSubgraphExecuteDebug< TContext extends Record, ->(opts: { logger: Logger }): GatewayPlugin { +>(): GatewayPlugin { let fetchAPI: FetchAPI; return { onYogaInit({ yoga }) { fetchAPI = yoga.fetchAPI; }, - onSubgraphExecute({ executionRequest, logger = opts.logger }) { - const subgraphExecuteHookLogger = logger.child({ + onSubgraphExecute({ executionRequest }) { + const log = executionRequest.context?.log.child({ subgraphExecuteId: fetchAPI.crypto.randomUUID(), }); - const subgraphExecuteStartLogger = subgraphExecuteHookLogger.child( - 'subgraph-execute-start', - ); - subgraphExecuteStartLogger.debug(() => { + if (!log) { + throw new Error('Logger is not available in the execution context'); + } + log.debug(() => { const logData: Record = {}; if (executionRequest.document) { logData['query'] = defaultPrintFn(executionRequest.document); @@ -30,28 +29,25 @@ export function useSubgraphExecuteDebug< logData['variables'] = executionRequest.variables; } return logData; - }); + }, 'subgraph-execute-start'); const start = performance.now(); return function onSubgraphExecuteDone({ result }) { - const subgraphExecuteEndLogger = subgraphExecuteHookLogger.child( - 'subgraph-execute-end', - ); if (isAsyncIterable(result)) { return { onNext({ result }) { - const subgraphExecuteNextLogger = subgraphExecuteHookLogger.child( - 'subgraph-execute-next', - ); - subgraphExecuteNextLogger.debug(result); + log.debug(result, 'subgraph-execute-next'); }, onEnd() { - subgraphExecuteEndLogger.debug(() => ({ - duration: performance.now() - start, - })); + log.debug( + () => ({ + duration: performance.now() - start, + }), + 'subgraph-execute-end', + ); }, }; } - subgraphExecuteEndLogger.debug(result); + log.debug(result, 'subgraph-execute-done'); return void 0; }; }, From 8c8c6cc521201e24242721cadb6f05e6196e3c08 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Tue, 8 Apr 2025 15:13:56 +0200 Subject: [PATCH 034/157] any attr value --- packages/logger/src/utils.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/logger/src/utils.ts b/packages/logger/src/utils.ts index 11556cbf9..4a97b8dfb 100644 --- a/packages/logger/src/utils.ts +++ b/packages/logger/src/utils.ts @@ -2,6 +2,7 @@ import fastSafeStringify from 'fast-safe-stringify'; import { LogLevel } from './Logger'; export type AttributeValue = + | any // this any will replace all other elements in the union, but is necessary for passing "interfaces" as attributes | string | number | boolean From 3a42983934da5bb3ce2c0e21114e9a51c5563d3f Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Tue, 8 Apr 2025 15:57:56 +0200 Subject: [PATCH 035/157] fetch with new log --- packages/runtime/src/plugins/useFetchDebug.ts | 43 +++++++++---------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/packages/runtime/src/plugins/useFetchDebug.ts b/packages/runtime/src/plugins/useFetchDebug.ts index d6a9eb207..fda8bbec0 100644 --- a/packages/runtime/src/plugins/useFetchDebug.ts +++ b/packages/runtime/src/plugins/useFetchDebug.ts @@ -1,38 +1,37 @@ -import type { Logger } from '@graphql-mesh/types'; import { FetchAPI } from 'graphql-yoga'; import type { GatewayPlugin } from '../types'; -export function useFetchDebug>(opts: { - logger: Logger; -}): GatewayPlugin { +export function useFetchDebug< + TContext extends Record, +>(): GatewayPlugin { let fetchAPI: FetchAPI; return { onYogaInit({ yoga }) { fetchAPI = yoga.fetchAPI; }, - onFetch({ url, options, logger = opts.logger }) { + onFetch({ url, options, context }) { const fetchId = fetchAPI.crypto.randomUUID(); - const fetchLogger = logger.child({ - fetchId, - }); - const httpFetchRequestLogger = fetchLogger.child('http-fetch-request'); - httpFetchRequestLogger.debug(() => ({ - url, - ...(options || {}), - body: options?.body, - headers: options?.headers, - signal: options?.signal?.aborted ? options?.signal?.reason : false, - })); + const log = context.log.child({ fetchId }); + log.debug( + () => ({ + url, + ...(options || {}), + body: options?.body, + headers: options?.headers, + signal: options?.signal?.aborted ? options?.signal?.reason : false, + }), + 'http-fetch-request', + ); const start = performance.now(); return function onFetchDone({ response }) { - const httpFetchResponseLogger = fetchLogger.child( + log.debug( + () => ({ + status: response.status, + headers: Object.fromEntries(response.headers.entries()), + duration: performance.now() - start, + }), 'http-fetch-response', ); - httpFetchResponseLogger.debug(() => ({ - status: response.status, - headers: Object.fromEntries(response.headers.entries()), - duration: performance.now() - start, - })); }; }, }; From 333389ee501a224450b01d7ea60cb9b86a64aab6 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Tue, 8 Apr 2025 17:24:19 +0200 Subject: [PATCH 036/157] logger attrs parser and stuff --- packages/logger/src/Logger.ts | 69 +++++++++++++++++-------- packages/logger/src/utils.ts | 77 ++++++++++++++++++++-------- packages/logger/tests/Logger.test.ts | 69 ++++++++++++++++++++++++- 3 files changed, 172 insertions(+), 43 deletions(-) diff --git a/packages/logger/src/Logger.ts b/packages/logger/src/Logger.ts index 8524253b6..74200dbfd 100644 --- a/packages/logger/src/Logger.ts +++ b/packages/logger/src/Logger.ts @@ -4,9 +4,10 @@ import { getEnv, isPromise, logLevel, + MaybeLazy, + parseAttrs, shouldLog, truthyEnv, - unwrapAttrs, } from './utils'; import { ConsoleLogWriter, LogWriter } from './writers'; @@ -31,7 +32,7 @@ export interface LoggerOptions { * The attributes to include in all logs. Is mainly used to pass the parent * attributes when creating {@link Logger.child child loggers}. */ - attrs?: Attributes; + attrs?: MaybeLazy; /** * The log writers to use when writing logs. * @@ -43,7 +44,7 @@ export interface LoggerOptions { export class Logger implements LogWriter { #level: LogLevel | false; #prefix: string | undefined; - #attrs: Attributes | undefined; + #attrs: MaybeLazy | undefined; #writers: [LogWriter, ...LogWriter[]]; #pendingWrites = new Set>(); @@ -103,8 +104,11 @@ export class Logger implements LogWriter { // public child(prefix: string): Logger; - public child(attrs: Attributes, prefix?: string): Logger; - public child(prefixOrAttrs: string | Attributes, prefix?: string): Logger { + public child(attrs: MaybeLazy, prefix?: string): Logger; + public child( + prefixOrAttrs: string | MaybeLazy, + prefix?: string, + ): Logger { if (typeof prefixOrAttrs === 'string') { return new Logger({ prefix: prefixOrAttrs, @@ -121,17 +125,17 @@ export class Logger implements LogWriter { // public log(level: LogLevel): void; - public log(level: LogLevel, attrs: Attributes): void; + public log(level: LogLevel, attrs: MaybeLazy): void; public log(level: LogLevel, msg: string, ...interpol: unknown[]): void; public log( level: LogLevel, - attrs: Attributes, + attrs: MaybeLazy, msg: string, ...interpol: unknown[] ): void; public log( level: LogLevel, - maybeAttrsOrMsg?: Attributes | string | null | undefined, + maybeAttrsOrMsg?: MaybeLazy | string | null | undefined, ...rest: unknown[] ): void { if (!shouldLog(this.#level, level)) { @@ -139,7 +143,7 @@ export class Logger implements LogWriter { } let msg: string | undefined; - let attrs: Attributes | undefined; + let attrs: MaybeLazy | undefined; if (typeof maybeAttrsOrMsg === 'string') { msg = maybeAttrsOrMsg; } else if (maybeAttrsOrMsg) { @@ -154,17 +158,24 @@ export class Logger implements LogWriter { msg = `${this.#prefix.trim()} ${msg || ''}`.trim(); // we trim everything because maybe the "msg" is empty } - attrs = this.#attrs ? { ...this.#attrs, ...attrs } : attrs; - attrs = attrs ? unwrapAttrs(attrs) : attrs; + attrs = attrs ? parseAttrs(attrs) : attrs; + attrs = this.#attrs ? { ...parseAttrs(this.#attrs), ...attrs } : attrs; msg = msg ? format(msg, rest) : msg; this.write(level, attrs, msg); + if (truthyEnv('LOG_TRACE_LOGS')) { + console.trace('👆'); + } } public trace(): void; - public trace(attrs: Attributes): void; + public trace(attrs: MaybeLazy): void; public trace(msg: string, ...interpol: unknown[]): void; - public trace(attrs: Attributes, msg: string, ...interpol: unknown[]): void; + public trace( + attrs: MaybeLazy, + msg: string, + ...interpol: unknown[] + ): void; public trace(...args: any): void { this.log( 'trace', @@ -174,9 +185,13 @@ export class Logger implements LogWriter { } public debug(): void; - public debug(attrs: Attributes): void; + public debug(attrs: MaybeLazy): void; public debug(msg: string, ...interpol: unknown[]): void; - public debug(attrs: Attributes, msg: string, ...interpol: unknown[]): void; + public debug( + attrs: MaybeLazy, + msg: string, + ...interpol: unknown[] + ): void; public debug(...args: any): void { this.log( 'debug', @@ -186,9 +201,13 @@ export class Logger implements LogWriter { } public info(): void; - public info(attrs: Attributes): void; + public info(attrs: MaybeLazy): void; public info(msg: string, ...interpol: unknown[]): void; - public info(attrs: Attributes, msg: string, ...interpol: unknown[]): void; + public info( + attrs: MaybeLazy, + msg: string, + ...interpol: unknown[] + ): void; public info(...args: any): void { this.log( 'info', @@ -198,9 +217,13 @@ export class Logger implements LogWriter { } public warn(): void; - public warn(attrs: Attributes): void; + public warn(attrs: MaybeLazy): void; public warn(msg: string, ...interpol: unknown[]): void; - public warn(attrs: Attributes, msg: string, ...interpol: unknown[]): void; + public warn( + attrs: MaybeLazy, + msg: string, + ...interpol: unknown[] + ): void; public warn(...args: any): void { this.log( 'warn', @@ -210,9 +233,13 @@ export class Logger implements LogWriter { } public error(): void; - public error(attrs: Attributes): void; + public error(attrs: MaybeLazy): void; public error(msg: string, ...interpol: unknown[]): void; - public error(attrs: Attributes, msg: string, ...interpol: unknown[]): void; + public error( + attrs: MaybeLazy, + msg: string, + ...interpol: unknown[] + ): void; public error(...args: any): void { this.log( 'error', diff --git a/packages/logger/src/utils.ts b/packages/logger/src/utils.ts index 4a97b8dfb..c7d6f9a98 100644 --- a/packages/logger/src/utils.ts +++ b/packages/logger/src/utils.ts @@ -1,8 +1,9 @@ import fastSafeStringify from 'fast-safe-stringify'; import { LogLevel } from './Logger'; +export type MaybeLazy = T | (() => T); + export type AttributeValue = - | any // this any will replace all other elements in the union, but is necessary for passing "interfaces" as attributes | string | number | boolean @@ -11,10 +12,10 @@ export type AttributeValue = | Object // redundant, but this will allow _any_ object be the value | null | undefined - | (() => AttributeValue); // lazy attribute + // TODO: remove `any`. this any will replace all other elements in the union, but is necessary for passing "interfaces" as attributes + | any; export type Attributes = - | (() => Attributes) | AttributeValue[] | { [key: string | number]: AttributeValue }; @@ -68,12 +69,7 @@ export function jsonStringify(val: unknown, pretty?: boolean): string { val, (_key, val) => { if (val instanceof Error) { - // TODO: also handle graphql errors, and maybe all other errors that can contain more properties - return { - name: val.name, - message: val.message, - stack: val.stack, - }; + return objectifyError(val); } return val; }, @@ -81,25 +77,39 @@ export function jsonStringify(val: unknown, pretty?: boolean): string { ); } -/** Recursivelly unwrapps the lazy attributes. */ -export function unwrapAttrs(attrs: Attributes, depth = 0): Attributes { +/** Recursivelly unwrapps the lazy attributes and parses instances of classes. */ +export function parseAttrs( + attrs: MaybeLazy, + depth = 0, +): Attributes { if (depth > 10) { throw new Error('Too much recursion while unwrapping function attributes'); } if (typeof attrs === 'function') { - return unwrapAttrs(attrs(), depth + 1); + return parseAttrs(attrs(), depth + 1); + } + + if (Array.isArray(attrs)) { + return attrs.map((val) => unwrapAttrVal(val, depth + 1)); } - const unwrapped: Attributes = {}; - for (const key of Object.keys(attrs)) { - const val = attrs[key as keyof typeof attrs]; - unwrapped[key] = unwrapAttrVal(val, depth + 1); + if (Object.prototype.toString.call(attrs) === '[object Object]') { + const unwrapped: Attributes = {}; + for (const key of Object.keys(attrs)) { + const val = attrs[key as keyof typeof attrs]; + unwrapped[key] = unwrapAttrVal(val, depth + 1); + } + return unwrapped; } - return unwrapped; + + return objectifyClass(attrs); } -function unwrapAttrVal(attr: AttributeValue, depth = 0): AttributeValue { +function unwrapAttrVal( + attr: MaybeLazy, + depth = 0, +): AttributeValue { if (depth > 10) { throw new Error( 'Too much recursion while unwrapping function attribute values', @@ -118,7 +128,6 @@ function unwrapAttrVal(attr: AttributeValue, depth = 0): AttributeValue { return unwrapAttrVal(attr(), depth + 1); } - // unwrap array items if (Array.isArray(attr)) { return attr.map((val) => unwrapAttrVal(val, depth + 1)); } @@ -135,13 +144,41 @@ function unwrapAttrVal(attr: AttributeValue, depth = 0): AttributeValue { } // very likely an instance of something, dont unwrap it - return attr; + return objectifyClass(attr); } function isPrimitive(val: unknown): val is string | number | boolean { return val !== Object(val); } +function objectifyClass(val: unknown): Record { + if (!val) { + // TODO: this should never happen, objectify class should not be called on empty values + return {}; + } + const props: Record = {}; + for (const propName of Object.getOwnPropertyNames(val)) { + props[propName] = val[propName as keyof typeof val]; + } + for (const protoPropName of Object.getOwnPropertyNames( + Object.getPrototypeOf(val), + )) { + const propVal = val[protoPropName as keyof typeof val]; + if (typeof propVal === 'function') { + continue; + } + props[protoPropName] = propVal; + } + return { + ...props, + class: val.constructor.name, + }; +} + +function objectifyError(err: Error) { + return objectifyClass(err); +} + export function getEnv(key: string): string | undefined { return ( globalThis.process?.env?.[key] || diff --git a/packages/logger/tests/Logger.test.ts b/packages/logger/tests/Logger.test.ts index 273192bd1..c11efda0b 100644 --- a/packages/logger/tests/Logger.test.ts +++ b/packages/logger/tests/Logger.test.ts @@ -13,8 +13,11 @@ function createTLogger(opts?: Partial) { it('should write logs with levels, message and attributes', () => { const [log, writter] = createTLogger(); + const err = new Error('Woah!'); + err.stack = ''; + log.log('info'); - log.log('info', { hello: 'world', err: new Error('Woah!') }, 'Hello, world!'); + log.log('info', { hello: 'world', err }, 'Hello, world!'); log.log('info', '2nd Hello, world!'); expect(writter.logs).toMatchInlineSnapshot(` @@ -24,7 +27,12 @@ it('should write logs with levels, message and attributes', () => { }, { "attrs": { - "err": [Error: Woah!], + "err": { + "class": "Error", + "message": "Woah!", + "name": "Error", + "stack": "", + }, "hello": "world", }, "level": "info", @@ -228,3 +236,60 @@ it('should format string', () => { ] `); }); + +it('should write logs with unexpected attributes', () => { + const [log, writer] = createTLogger(); + + const err = new Error('Woah!'); + err.stack = ''; + + log.info(err); + + log.info([err, { denis: 'badurina' }, ['hello'], 'world']); + + class MyClass { + constructor(public someprop: string) {} + get getsomeprop() { + return this.someprop; + } + } + log.info(new MyClass('hey')); + + expect(writer.logs).toMatchInlineSnapshot(` + [ + { + "attrs": { + "class": "Error", + "message": "Woah!", + "name": "Error", + "stack": "", + }, + "level": "info", + }, + { + "attrs": [ + { + "class": "Error", + "message": "Woah!", + "name": "Error", + "stack": "", + }, + { + "denis": "badurina", + }, + [ + "hello", + ], + "world", + ], + "level": "info", + }, + { + "attrs": { + "someprop": "hey", + }, + "level": "info", + }, + ] + `); +}); From 89880ab273eaf379c26ebba9c1be5f58e2711c22 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Tue, 8 Apr 2025 19:29:25 +0200 Subject: [PATCH 037/157] inherit child and some fixes --- packages/logger/src/Logger.ts | 22 ++++++------- packages/logger/src/index.ts | 2 ++ packages/logger/tests/Logger.test.ts | 48 ++++++++++++++++++++++++++-- 3 files changed, 59 insertions(+), 13 deletions(-) diff --git a/packages/logger/src/Logger.ts b/packages/logger/src/Logger.ts index 74200dbfd..0e919d08a 100644 --- a/packages/logger/src/Logger.ts +++ b/packages/logger/src/Logger.ts @@ -32,7 +32,7 @@ export interface LoggerOptions { * The attributes to include in all logs. Is mainly used to pass the parent * attributes when creating {@link Logger.child child loggers}. */ - attrs?: MaybeLazy; + attrs?: Attributes; /** * The log writers to use when writing logs. * @@ -44,7 +44,7 @@ export interface LoggerOptions { export class Logger implements LogWriter { #level: LogLevel | false; #prefix: string | undefined; - #attrs: MaybeLazy | undefined; + #attrs: Attributes | undefined; #writers: [LogWriter, ...LogWriter[]]; #pendingWrites = new Set>(); @@ -104,20 +104,20 @@ export class Logger implements LogWriter { // public child(prefix: string): Logger; - public child(attrs: MaybeLazy, prefix?: string): Logger; - public child( - prefixOrAttrs: string | MaybeLazy, - prefix?: string, - ): Logger { + public child(attrs: Attributes, prefix?: string): Logger; + public child(prefixOrAttrs: string | Attributes, prefix?: string): Logger { if (typeof prefixOrAttrs === 'string') { return new Logger({ - prefix: prefixOrAttrs, + level: this.#level, + prefix: (this.#prefix || '') + prefixOrAttrs, + attrs: this.#attrs, writers: this.#writers, }); } return new Logger({ - prefix, - attrs: prefixOrAttrs, + level: this.#level, + prefix: (this.#prefix || '') + (prefix || '') || undefined, + attrs: { ...this.#attrs, ...prefixOrAttrs }, writers: this.#writers, }); } @@ -155,7 +155,7 @@ export class Logger implements LogWriter { } if (this.#prefix) { - msg = `${this.#prefix.trim()} ${msg || ''}`.trim(); // we trim everything because maybe the "msg" is empty + msg = `${this.#prefix}${msg || ''}`.trim(); // we trim everything because maybe the "msg" is empty } attrs = attrs ? parseAttrs(attrs) : attrs; diff --git a/packages/logger/src/index.ts b/packages/logger/src/index.ts index c5430cdd9..d864c7e98 100644 --- a/packages/logger/src/index.ts +++ b/packages/logger/src/index.ts @@ -1,2 +1,4 @@ export * from './Logger'; export * from './writers'; +/** @deprecated Please migrate to using the './Logger' instead. */ +export * from './LegacyLogger'; diff --git a/packages/logger/tests/Logger.test.ts b/packages/logger/tests/Logger.test.ts index c11efda0b..6363e2394 100644 --- a/packages/logger/tests/Logger.test.ts +++ b/packages/logger/tests/Logger.test.ts @@ -98,7 +98,7 @@ it('should include attributes in child loggers', () => { it('should include prefix in child loggers', () => { let [log, writter] = createTLogger(); - log = log.child('prefix'); + log = log.child('prefix '); log.info('hello'); @@ -115,7 +115,7 @@ it('should include prefix in child loggers', () => { it('should include attributes and prefix in child loggers', () => { let [log, writter] = createTLogger(); - log = log.child({ par: 'ent' }, 'prefix'); + log = log.child({ par: 'ent' }, 'prefix '); log.info('hello'); @@ -132,6 +132,50 @@ it('should include attributes and prefix in child loggers', () => { `); }); +it('should have child inherit parent log level', () => { + let [log, writter] = createTLogger({ level: 'warn' }); + + log = log.child({ par: 'ent' }); + + log.debug('no hello'); + log.info('still no hello'); + log.warn('hello'); + + expect(writter.logs).toMatchInlineSnapshot(` + [ + { + "attrs": { + "par": "ent", + }, + "level": "warn", + "msg": "hello", + }, + ] + `); +}); + +it('should include attributes and prefix in nested child loggers', () => { + let [log, writter] = createTLogger(); + + log = log.child({ par: 'ent' }, 'prefix '); + log = log.child({ par2: 'ent2' }, 'prefix2 '); + + log.info('hello'); + + expect(writter.logs).toMatchInlineSnapshot(` + [ + { + "attrs": { + "par": "ent", + "par2": "ent2", + }, + "level": "info", + "msg": "prefix prefix2 hello", + }, + ] + `); +}); + it('should unwrap lazy attributes', () => { const [log, writter] = createTLogger(); From 3883b0736f9705b9382d4c0c0bb46b593efe9776 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Wed, 9 Apr 2025 15:50:58 +0200 Subject: [PATCH 038/157] refactor --- e2e/retry-timeout/gateway.config.ts | 6 +- .../delegate/src/finalizeGatewayRequest.ts | 3 - packages/fusion-runtime/package.json | 1 + packages/fusion-runtime/src/executor.ts | 8 +-- .../fusion-runtime/src/unifiedGraphManager.ts | 49 ++++++-------- packages/fusion-runtime/src/utils.ts | 50 +++++++------- packages/fusion-runtime/tests/polling.test.ts | 23 ++++--- packages/gateway/package.json | 1 + packages/gateway/src/bin.ts | 4 +- packages/gateway/src/cli.ts | 8 +-- packages/gateway/src/commands/handleFork.ts | 20 +++--- .../src/commands/handleLoggingOption.ts | 18 ----- .../src/commands/handleReportingConfig.ts | 14 ++-- packages/gateway/src/commands/proxy.ts | 6 +- packages/gateway/src/commands/subgraph.ts | 6 +- packages/gateway/src/commands/supergraph.ts | 54 ++++++++------- packages/gateway/src/config.ts | 9 ++- packages/gateway/src/index.ts | 2 +- packages/gateway/src/servers/bun.ts | 2 +- packages/gateway/src/servers/nodeHttp.ts | 12 ++-- .../src/servers/startServerForRuntime.ts | 2 +- packages/gateway/src/servers/types.ts | 2 +- .../hmac-upstream-signature/src/index.ts | 27 +++----- packages/plugins/prometheus/package.json | 1 + packages/plugins/prometheus/src/index.ts | 9 ++- packages/runtime/package.json | 1 + packages/runtime/src/createGatewayRuntime.ts | 63 +++++++++-------- .../runtime/src/createLoggerFromLogging.ts | 13 ++++ packages/runtime/src/getDefaultLogger.ts | 67 ------------------- packages/runtime/src/getReportingPlugin.ts | 2 +- .../runtime/src/handleUnifiedGraphConfig.ts | 3 +- packages/runtime/src/index.ts | 5 +- packages/runtime/src/plugins/useCacheDebug.ts | 6 ++ .../src/plugins/useDelegationPlanDebug.ts | 56 +++++++--------- .../runtime/src/plugins/useDemandControl.ts | 14 ++-- .../runtime/src/plugins/useHiveConsole.ts | 6 +- packages/runtime/src/plugins/useRequestId.ts | 10 +-- .../src/plugins/useRetryOnSchemaReload.ts | 18 +++-- packages/runtime/src/plugins/useWebhooks.ts | 6 +- packages/runtime/src/types.ts | 16 +++-- packages/transports/common/package.json | 1 + packages/transports/common/src/types.ts | 9 ++- .../transports/http-callback/src/index.ts | 21 +++--- packages/transports/ws/src/index.ts | 22 +++--- yarn.lock | 7 +- 45 files changed, 314 insertions(+), 369 deletions(-) delete mode 100644 packages/gateway/src/commands/handleLoggingOption.ts create mode 100644 packages/runtime/src/createLoggerFromLogging.ts delete mode 100644 packages/runtime/src/getDefaultLogger.ts diff --git a/e2e/retry-timeout/gateway.config.ts b/e2e/retry-timeout/gateway.config.ts index 0d4e508f8..a929fb4ba 100644 --- a/e2e/retry-timeout/gateway.config.ts +++ b/e2e/retry-timeout/gateway.config.ts @@ -6,13 +6,13 @@ export const gatewayConfig = defineConfig({ maxRetries: 4, }, upstreamTimeout: 300, - plugins(ctx) { + plugins() { return [ ...(process.env['DEDUPLICATE_REQUEST'] ? [useDeduplicateRequest()] : []), { - onFetch() { + onFetch({ context }) { i++; - ctx.logger.info(`[FETCHING] #${i}`); + context.log.info(`[FETCHING] #${i}`); }, }, ]; diff --git a/packages/delegate/src/finalizeGatewayRequest.ts b/packages/delegate/src/finalizeGatewayRequest.ts index 661a35b34..413a3a175 100644 --- a/packages/delegate/src/finalizeGatewayRequest.ts +++ b/packages/delegate/src/finalizeGatewayRequest.ts @@ -562,9 +562,6 @@ function finalizeSelectionSet( leave: (node) => { const type = typeInfo.getType(); if (type == null) { - // console.warn( - // `Invalid type for node: ${typeInfo.getParentType()?.name}.${node.name.value}`, - // ); return null; } const namedType = getNamedType(type); diff --git a/packages/fusion-runtime/package.json b/packages/fusion-runtime/package.json index cb4d31c24..ac2af2a18 100644 --- a/packages/fusion-runtime/package.json +++ b/packages/fusion-runtime/package.json @@ -45,6 +45,7 @@ "dependencies": { "@envelop/core": "^5.2.3", "@envelop/instrumentation": "^1.0.0", + "@graphql-hive/logger": "workspace:^", "@graphql-mesh/cross-helpers": "^0.4.10", "@graphql-mesh/transport-common": "workspace:^", "@graphql-mesh/types": "^0.104.0", diff --git a/packages/fusion-runtime/src/executor.ts b/packages/fusion-runtime/src/executor.ts index 2746160de..870ce38dd 100644 --- a/packages/fusion-runtime/src/executor.ts +++ b/packages/fusion-runtime/src/executor.ts @@ -34,7 +34,7 @@ export function getExecutorForUnifiedGraph( () => unifiedGraphManager.getContext(execReq.context), (context) => { function handleExecutor(executor: Executor) { - opts?.transportContext?.logger?.debug( + opts?.transportContext.log.debug( 'Executing request on unified graph', () => print(execReq.document), ); @@ -50,7 +50,7 @@ export function getExecutorForUnifiedGraph( return handleMaybePromise( () => unifiedGraphManager.getUnifiedGraph(), (unifiedGraph) => { - opts?.transportContext?.logger?.debug( + opts?.transportContext.log.debug( 'Executing request on unified graph', () => print(execReq.document), ); @@ -70,9 +70,7 @@ export function getExecutorForUnifiedGraph( enumerable: true, get() { return function unifiedGraphExecutorDispose() { - opts?.transportContext?.logger?.debug( - 'Disposing unified graph executor', - ); + opts?.transportContext.log.debug('Disposing unified graph executor'); return unifiedGraphManager[DisposableSymbols.asyncDispose](); }; }, diff --git a/packages/fusion-runtime/src/unifiedGraphManager.ts b/packages/fusion-runtime/src/unifiedGraphManager.ts index 26f03a5e7..75e9e05f9 100644 --- a/packages/fusion-runtime/src/unifiedGraphManager.ts +++ b/packages/fusion-runtime/src/unifiedGraphManager.ts @@ -1,8 +1,9 @@ +import type { Logger } from '@graphql-hive/logger'; import type { TransportContext, TransportEntry, } from '@graphql-mesh/transport-common'; -import type { Logger, OnDelegateHook } from '@graphql-mesh/types'; +import type { OnDelegateHook } from '@graphql-mesh/types'; import { dispose, isDisposable } from '@graphql-mesh/utils'; import { CRITICAL_ERROR } from '@graphql-tools/executor'; import type { @@ -68,8 +69,7 @@ export interface UnifiedGraphHandlerOpts { onDelegationPlanHooks?: OnDelegationPlanHook[]; onDelegationStageExecuteHooks?: OnDelegationStageExecuteHook[]; onDelegateHooks?: OnDelegateHook[]; - - logger?: Logger; + log: Logger; } export interface UnifiedGraphHandlerResult { @@ -93,7 +93,7 @@ export interface UnifiedGraphManagerOptions { additionalResolvers?: | IResolvers | IResolvers[]; - transportContext?: TransportContext; + transportContext: TransportContext; onSubgraphExecuteHooks?: OnSubgraphExecuteHook[]; // TODO: Will be removed later once we get rid of v0 onDelegateHooks?: OnDelegateHook[]; @@ -105,7 +105,6 @@ export interface UnifiedGraphManagerOptions { */ batch?: boolean; instrumentation?: () => Instrumentation | undefined; - onUnifiedGraphChange?(newUnifiedGraph: GraphQLSchema): void; } @@ -157,7 +156,7 @@ export class UnifiedGraphManager implements AsyncDisposable { this.onDelegationStageExecuteHooks = opts?.onDelegationStageExecuteHooks || []; if (opts.pollingInterval != null) { - opts.transportContext?.logger?.debug( + opts.transportContext.log.debug( `Starting polling to Supergraph with interval ${millisecondsToStr(opts.pollingInterval)}`, ); } @@ -170,14 +169,14 @@ export class UnifiedGraphManager implements AsyncDisposable { this.lastLoadTime != null && Date.now() - this.lastLoadTime >= this.opts.pollingInterval ) { - this.opts?.transportContext?.logger?.debug(`Polling Supergraph`); + this.opts?.transportContext.log.debug(`Polling Supergraph`); this.polling$ = handleMaybePromise( () => this.getAndSetUnifiedGraph(), () => { this.polling$ = undefined; }, (err) => { - this.opts.transportContext?.logger?.error( + this.opts.transportContext.log.error( 'Failed to poll Supergraph', err, ); @@ -187,19 +186,18 @@ export class UnifiedGraphManager implements AsyncDisposable { } if (!this.unifiedGraph) { if (!this.initialUnifiedGraph$) { - this.opts?.transportContext?.logger?.debug( + this.opts?.transportContext.log.debug( 'Fetching the initial Supergraph', ); - if (this.opts.transportContext?.cache) { - this.opts.transportContext?.logger?.debug( + if (this.opts.transportContext.cache) { + this.opts.transportContext.log.debug( `Searching for Supergraph in cache under key "${UNIFIEDGRAPH_CACHE_KEY}"...`, ); this.initialUnifiedGraph$ = handleMaybePromise( - () => - this.opts.transportContext?.cache?.get(UNIFIEDGRAPH_CACHE_KEY), + () => this.opts.transportContext.cache?.get(UNIFIEDGRAPH_CACHE_KEY), (cachedUnifiedGraph) => { if (cachedUnifiedGraph) { - this.opts.transportContext?.logger?.debug( + this.opts.transportContext.log.debug( 'Found Supergraph in cache', ); return this.handleLoadedUnifiedGraph(cachedUnifiedGraph, true); @@ -217,9 +215,7 @@ export class UnifiedGraphManager implements AsyncDisposable { () => this.initialUnifiedGraph$!, (v) => { this.initialUnifiedGraph$ = undefined; - this.opts.transportContext?.logger?.debug( - 'Initial Supergraph fetched', - ); + this.opts.transportContext.log.debug('Initial Supergraph fetched'); return v; }, ); @@ -240,7 +236,7 @@ export class UnifiedGraphManager implements AsyncDisposable { this.lastLoadedUnifiedGraph != null && compareSchemas(loadedUnifiedGraph, this.lastLoadedUnifiedGraph) ) { - this.opts.transportContext?.logger?.debug( + this.opts.transportContext.log.debug( 'Supergraph has not been changed, skipping...', ); this.lastLoadTime = Date.now(); @@ -267,11 +263,11 @@ export class UnifiedGraphManager implements AsyncDisposable { // 60 seconds making sure the unifiedgraph is not kept forever // NOTE: we default to 60s because Cloudflare KV TTL does not accept anything less 60; - this.opts.transportContext.logger?.debug( + this.opts.transportContext.log.debug( `Caching Supergraph with TTL ${ttl}s`, ); const logCacheSetError = (e: unknown) => { - this.opts.transportContext?.logger?.debug( + this.opts.transportContext.log.debug( `Unable to store Supergraph in cache under key "${UNIFIEDGRAPH_CACHE_KEY}" with TTL ${ttl}s`, e, ); @@ -290,7 +286,7 @@ export class UnifiedGraphManager implements AsyncDisposable { logCacheSetError(e); } } catch (e) { - this.opts.transportContext.logger?.error( + this.opts.transportContext.log.error( 'Failed to initiate caching of Supergraph', e, ); @@ -318,7 +314,7 @@ export class UnifiedGraphManager implements AsyncDisposable { onDelegationPlanHooks: this.onDelegationPlanHooks, onDelegationStageExecuteHooks: this.onDelegationStageExecuteHooks, onDelegateHooks: this.opts.onDelegateHooks, - logger: this.opts.transportContext?.logger, + log: this.opts.transportContext.log, }); const transportExecutorStack = new AsyncDisposableStack(); const onSubgraphExecute = getOnSubgraphExecute({ @@ -360,7 +356,7 @@ export class UnifiedGraphManager implements AsyncDisposable { }, }, ); - this.opts.transportContext?.logger?.debug( + this.opts.transportContext.log.debug( 'Supergraph has been changed, updating...', ); } @@ -372,7 +368,7 @@ export class UnifiedGraphManager implements AsyncDisposable { }, (err) => { this.disposeReason = undefined; - this.opts.transportContext?.logger?.error( + this.opts.transportContext.log.error( 'Failed to dispose the existing transports and executors', err, ); @@ -396,10 +392,7 @@ export class UnifiedGraphManager implements AsyncDisposable { (loadedUnifiedGraph: string | GraphQLSchema | DocumentNode) => this.handleLoadedUnifiedGraph(loadedUnifiedGraph), (err) => { - this.opts.transportContext?.logger?.error( - 'Failed to load Supergraph', - err, - ); + this.opts.transportContext.log.error(err, 'Failed to load Supergraph'); this.lastLoadTime = Date.now(); this.disposeReason = undefined; this.polling$ = undefined; diff --git a/packages/fusion-runtime/src/utils.ts b/packages/fusion-runtime/src/utils.ts index c43645cae..d9407a49e 100644 --- a/packages/fusion-runtime/src/utils.ts +++ b/packages/fusion-runtime/src/utils.ts @@ -1,4 +1,5 @@ import { getInstrumented } from '@envelop/instrumentation'; +import { LegacyLogger, type Logger } from '@graphql-hive/logger'; import { defaultPrintFn, type Transport, @@ -7,7 +8,6 @@ import { type TransportGetSubgraphExecutor, type TransportGetSubgraphExecutorOptions, } from '@graphql-mesh/transport-common'; -import type { Logger } from '@graphql-mesh/types'; import { isDisposable, iterateAsync, @@ -121,12 +121,12 @@ function getTransportExecutor({ }): MaybePromise { // TODO const kind = transportEntry?.kind || ''; - let logger = transportContext?.logger; - if (logger) { + let log = transportContext.log; + if (log) { if (subgraphName) { - logger = logger.child({ subgraph: subgraphName }); + log = log.child({ subgraph: subgraphName }); } - logger?.debug(`Loading transport "${kind}"`); + log.debug(`Loading transport "${kind}"`); } return handleMaybePromise( () => @@ -186,7 +186,7 @@ export const subgraphNameByExecutionRequest = new WeakMap< */ export function getOnSubgraphExecute({ onSubgraphExecuteHooks, - transportContext = {}, + transportContext, transportEntryMap, getSubgraphSchema, transportExecutorStack, @@ -197,7 +197,7 @@ export function getOnSubgraphExecute({ }: { onSubgraphExecuteHooks: OnSubgraphExecuteHook[]; transports?: Transports; - transportContext?: TransportContext; + transportContext: TransportContext; transportEntryMap: Record; getSubgraphSchema(subgraphName: string): GraphQLSchema; transportExecutorStack: AsyncDisposableStack; @@ -214,18 +214,18 @@ export function getOnSubgraphExecute({ let executor: Executor | undefined = subgraphExecutorMap.get(subgraphName); // If the executor is not initialized yet, initialize it if (executor == null) { - let logger = transportContext?.logger; - if (logger) { + let log = transportContext.log; + if (log) { const requestId = requestIdByRequest.get( executionRequest.context?.request, ); if (requestId) { - logger = logger.child({ requestId }); + log = log.child({ requestId }); } if (subgraphName) { - logger = logger.child({ subgraph: subgraphName }); + log = log.child({ subgraph: subgraphName }); } - logger.debug(`Initializing executor`); + log.debug('Initializing executor'); } // Lazy executor that loads transport executor on demand executor = function lazyExecutor(subgraphExecReq: ExecutionRequest) { @@ -285,7 +285,7 @@ export interface WrapExecuteWithHooksOptions { subgraphName: string; transportEntryMap?: Record; getSubgraphSchema: (subgraphName: string) => GraphQLSchema; - transportContext?: TransportContext; + transportContext: TransportContext; instrumentation: () => Instrumentation | undefined; } @@ -321,14 +321,17 @@ export function wrapExecutorWithHooks({ const requestId = baseExecutionRequest.context?.request && requestIdByRequest.get(baseExecutionRequest.context.request); - let execReqLogger = transportContext?.logger; + let execReqLogger = transportContext.log; if (execReqLogger) { if (requestId) { execReqLogger = execReqLogger.child({ requestId }); } - loggerForExecutionRequest.set(baseExecutionRequest, execReqLogger); + loggerForExecutionRequest.set( + baseExecutionRequest, + LegacyLogger.from(execReqLogger), + ); } - execReqLogger = execReqLogger?.child?.({ subgraph: subgraphName }); + execReqLogger = execReqLogger?.child({ subgraph: subgraphName }); if (onSubgraphExecuteHooks.length === 0) { return baseExecutor(baseExecutionRequest); } @@ -358,7 +361,7 @@ export function wrapExecutorWithHooks({ executor = newExecutor; }, requestId, - logger: execReqLogger, + log: execReqLogger, }), onSubgraphExecuteDoneHooks, ), @@ -474,7 +477,7 @@ export interface OnSubgraphExecutePayload { executor: Executor; setExecutor(executor: Executor): void; requestId?: string; - logger?: Logger; + log: Logger; } export interface OnSubgraphExecuteDonePayload { @@ -600,19 +603,18 @@ export function wrapMergedTypeResolver>( originalResolver: MergedTypeResolver, typeName: string, onDelegationStageExecuteHooks: OnDelegationStageExecuteHook[], - baseLogger?: Logger, + log: Logger, ): MergedTypeResolver { return (object, context, info, subschema, selectionSet, key, type) => { - let logger = baseLogger; let requestId: string | undefined; - if (logger && context['request']) { + if (log && context['request']) { requestId = requestIdByRequest.get(context['request']); if (requestId) { - logger = logger.child({ requestId }); + log = log.child({ requestId }); } } if (subschema.name) { - logger = logger?.child({ subgraph: subschema.name }); + log = log.child({ subgraph: subschema.name }); } let resolver = originalResolver as MergedTypeResolver; function setResolver(newResolver: MergedTypeResolver) { @@ -632,7 +634,7 @@ export function wrapMergedTypeResolver>( typeName, type, requestId, - logger, + logger: log, resolver, setResolver, }); diff --git a/packages/fusion-runtime/tests/polling.test.ts b/packages/fusion-runtime/tests/polling.test.ts index b8e9829ea..ba4d612d3 100644 --- a/packages/fusion-runtime/tests/polling.test.ts +++ b/packages/fusion-runtime/tests/polling.test.ts @@ -1,4 +1,5 @@ import { setTimeout } from 'timers/promises'; +import { Logger } from '@graphql-hive/logger'; import { getUnifiedGraphGracefully } from '@graphql-mesh/fusion-composition'; import { getExecutorForUnifiedGraph } from '@graphql-mesh/fusion-runtime'; import { @@ -21,7 +22,6 @@ import { import { ExecutionResult, GraphQLSchema, parse } from 'graphql'; import { createSchema } from 'graphql-yoga'; import { describe, expect, it, vi } from 'vitest'; -import { getDefaultLogger } from '../../runtime/src/getDefaultLogger'; import { UnifiedGraphManager } from '../src/unifiedGraphManager'; describe('Polling', () => { @@ -65,6 +65,9 @@ describe('Polling', () => { getUnifiedGraph: unifiedGraphFetcher, pollingInterval: pollingInterval, batch: false, + transportContext: { + log: new Logger({ level: false }), + }, transports() { return { getSubgraphExecutor() { @@ -203,6 +206,9 @@ describe('Polling', () => { getUnifiedGraph: unifiedGraphFetcher, pollingInterval: pollingInterval, batch: false, + transportContext: { + log: new Logger({ level: false }), + }, transports() { return { getSubgraphExecutor() { @@ -298,6 +304,9 @@ describe('Polling', () => { await using executor = getExecutorForUnifiedGraph({ getUnifiedGraph: unifiedGraphFetcher, pollingInterval: 1000, + transportContext: { + log: new Logger({ level: false }), + }, transports() { return { getSubgraphExecutor() { @@ -374,20 +383,18 @@ describe('Polling', () => { const unifiedGraphFetcher = vi.fn(() => { return graphDeferred ? graphDeferred.promise : unifiedGraph; }); - const logger = getDefaultLogger(); + const log = new Logger(); await using executor = getExecutorForUnifiedGraph({ getUnifiedGraph: unifiedGraphFetcher, pollingInterval: 10_000, - transportContext: { - logger, - }, + transportContext: { log }, transports() { - logger.debug('transports'); + log.debug('transports'); return { getSubgraphExecutor() { - logger.debug('getSubgraphExecutor'); + log.debug('getSubgraphExecutor'); return function dynamicExecutor(...args) { - logger.debug('dynamicExecutor'); + log.debug('dynamicExecutor'); return createDefaultExecutor(schema)(...args); }; }, diff --git a/packages/gateway/package.json b/packages/gateway/package.json index 3d57aafec..c9fb573bc 100644 --- a/packages/gateway/package.json +++ b/packages/gateway/package.json @@ -55,6 +55,7 @@ "@escape.tech/graphql-armor-max-tokens": "^2.5.0", "@graphql-hive/gateway-runtime": "workspace:^", "@graphql-hive/importer": "workspace:^", + "@graphql-hive/logger": "workspace:^", "@graphql-hive/plugin-aws-sigv4": "workspace:^", "@graphql-hive/plugin-deduplicate-request": "workspace:^", "@graphql-hive/pubsub": "workspace:^", diff --git a/packages/gateway/src/bin.ts b/packages/gateway/src/bin.ts index 5fce1be5e..42edd5dc7 100644 --- a/packages/gateway/src/bin.ts +++ b/packages/gateway/src/bin.ts @@ -3,7 +3,7 @@ import 'dotenv/config'; // inject dotenv options to process.env import module from 'node:module'; import type { InitializeData } from '@graphql-hive/importer/hooks'; -import { getDefaultLogger } from '../../runtime/src/getDefaultLogger'; +import { Logger } from '@graphql-hive/logger'; import { enableModuleCachingIfPossible, handleNodeWarnings, run } from './cli'; // @inject-version globalThis.__VERSION__ here @@ -20,7 +20,7 @@ module.register('@graphql-hive/importer/hooks', { enableModuleCachingIfPossible(); handleNodeWarnings(); -const log = getDefaultLogger(); +const log = new Logger(); run({ log }).catch((err) => { log.error(err); diff --git a/packages/gateway/src/cli.ts b/packages/gateway/src/cli.ts index 45e8f1b88..2c9e4258b 100644 --- a/packages/gateway/src/cli.ts +++ b/packages/gateway/src/cli.ts @@ -15,16 +15,16 @@ import { type GatewayGraphOSReportingOptions, type GatewayHiveReportingOptions, } from '@graphql-hive/gateway-runtime'; +import { Logger } from '@graphql-hive/logger'; import type { AWSSignv4PluginOptions } from '@graphql-hive/plugin-aws-sigv4'; import { HivePubSub } from '@graphql-hive/pubsub'; import type UpstashRedisCache from '@graphql-mesh/cache-upstash-redis'; import type { JWTAuthPluginOptions } from '@graphql-mesh/plugin-jwt-auth'; import type { OpenTelemetryMeshPluginOptions } from '@graphql-mesh/plugin-opentelemetry'; import type { PrometheusPluginOptions } from '@graphql-mesh/plugin-prometheus'; -import type { KeyValueCache, Logger, YamlConfig } from '@graphql-mesh/types'; +import type { KeyValueCache, YamlConfig } from '@graphql-mesh/types'; import { renderGraphiQL } from '@graphql-yoga/render-graphiql'; import parseDuration from 'parse-duration'; -import { getDefaultLogger } from '../../runtime/src/getDefaultLogger'; import { addCommands } from './commands/index'; import { createDefaultConfigPaths } from './config'; import { getMaxConcurrency } from './getMaxConcurrency'; @@ -105,7 +105,7 @@ export interface GatewayCLIProxyConfig } export type KeyValueCacheFactoryFn = (ctx: { - logger: Logger; + log: Logger; pubsub: HivePubSub; cwd: string; }) => KeyValueCache; @@ -400,7 +400,7 @@ let cli = new Command() export async function run(userCtx: Partial) { const ctx: CLIContext = { - log: userCtx.log || getDefaultLogger(), + log: userCtx.log || new Logger(), productName: 'Hive Gateway', productDescription: 'Federated GraphQL Gateway', productPackageName: '@graphql-hive/gateway', diff --git a/packages/gateway/src/commands/handleFork.ts b/packages/gateway/src/commands/handleFork.ts index c093a90f8..7b72603dc 100644 --- a/packages/gateway/src/commands/handleFork.ts +++ b/packages/gateway/src/commands/handleFork.ts @@ -1,5 +1,5 @@ import cluster, { type Worker } from 'node:cluster'; -import type { Logger } from '@graphql-mesh/types'; +import type { Logger } from '@graphql-hive/logger'; import { registerTerminateHandler } from '@graphql-mesh/utils'; /** @@ -10,7 +10,7 @@ export function handleFork(log: Logger, config: { fork?: number }): boolean { if (cluster.isPrimary && config.fork && config.fork > 1) { const workers = new Set(); let expectedToExit = false; - log.debug(`Forking ${config.fork} workers`); + log.debug('Forking %d workers', config.fork); for (let i = 0; i < config.fork; i++) { const worker = cluster.fork(); const workerLogger = log.child({ worker: worker.id }); @@ -22,25 +22,23 @@ export function handleFork(log: Logger, config: { fork?: number }): boolean { logData['code'] = code; } if (expectedToExit) { - workerLogger.debug('exited', logData); + workerLogger.debug(logData, 'exited'); } else { workerLogger.error( - 'exited unexpectedly. A restart is recommended to ensure the stability of the service', logData, + 'Exited unexpectedly. A restart is recommended to ensure the stability of the service', ); } workers.delete(worker); if (!expectedToExit && workers.size === 0) { - log.error(`All workers exited unexpectedly. Exiting`, logData); + log.error(logData, 'All workers exited unexpectedly. Exiting...'); process.exit(1); } }); workers.add(worker); } registerTerminateHandler((signal) => { - log.info('Killing workers', { - signal, - }); + log.info('Killing workers on %s', signal); expectedToExit = true; workers.forEach((w) => { w.kill(signal); @@ -49,7 +47,11 @@ export function handleFork(log: Logger, config: { fork?: number }): boolean { return true; } } catch (e) { - log.error(`Error while forking workers: `, e); + log.error( + // @ts-expect-error very likely an instanceof error + e, + 'Error while forking workers', + ); } return false; } diff --git a/packages/gateway/src/commands/handleLoggingOption.ts b/packages/gateway/src/commands/handleLoggingOption.ts deleted file mode 100644 index 4b9c213b6..000000000 --- a/packages/gateway/src/commands/handleLoggingOption.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { - handleLoggingConfig as handleLoggingConfigRuntime, - LogLevel, -} from '@graphql-hive/gateway-runtime'; -import { Logger } from '@graphql-mesh/types'; -import { CLIContext } from '..'; - -export function handleLoggingConfig( - loggingConfig: - | boolean - | Logger - | LogLevel - | keyof typeof LogLevel - | undefined, - ctx: CLIContext, -) { - ctx.log = handleLoggingConfigRuntime(loggingConfig, ctx.log); -} diff --git a/packages/gateway/src/commands/handleReportingConfig.ts b/packages/gateway/src/commands/handleReportingConfig.ts index f6bea3354..3434d5da3 100644 --- a/packages/gateway/src/commands/handleReportingConfig.ts +++ b/packages/gateway/src/commands/handleReportingConfig.ts @@ -37,28 +37,28 @@ export function handleReportingConfig( if (cliOpts.hiveRegistryToken && cliOpts.hiveUsageAccessToken) { ctx.log.error( - `Cannot use "--hive-registry-token" with "--hive-usage-access-token". Please use "--hive-usage-target" and "--hive-usage-access-token" or the config instead.`, + 'Cannot use "--hive-registry-token" with "--hive-usage-access-token". Please use "--hive-usage-target" and "--hive-usage-access-token" or the config instead.', ); process.exit(1); } if (cliOpts.hiveRegistryToken && opts.hiveUsageTarget) { ctx.log.error( - `Cannot use "--hive-registry-token" with a target. Please use "--hive-usage-target" and "--hive-usage-access-token" or the config instead.`, + 'Cannot use "--hive-registry-token" with a target. Please use "--hive-usage-target" and "--hive-usage-access-token" or the config instead.', ); process.exit(1); } if (opts.hiveUsageTarget && !opts.hiveUsageAccessToken) { ctx.log.error( - `Hive usage target needs an access token. Please provide it through the "--hive-usage-access-token " option or the config.`, + 'Hive usage target needs an access token. Please provide it through the "--hive-usage-access-token " option or the config.', ); process.exit(1); } if (opts.hiveUsageAccessToken && !opts.hiveUsageTarget) { ctx.log.error( - `Hive usage access token needs a target. Please provide it through the "--hive-usage-target " option or the config.`, + 'Hive usage access token needs a target. Please provide it through the "--hive-usage-target " option or the config.', ); process.exit(1); } @@ -68,9 +68,9 @@ export function handleReportingConfig( if (hiveUsageAccessToken) { // different logs w and w/o the target to disambiguate if (opts.hiveUsageTarget) { - ctx.log.info(`Configuring Hive usage reporting`); + ctx.log.info('Configuring Hive usage reporting'); } else { - ctx.log.info(`Configuring Hive registry reporting`); + ctx.log.info('Configuring Hive registry reporting'); } return { ...loadedConfig.reporting, @@ -81,7 +81,7 @@ export function handleReportingConfig( } if (opts.apolloKey) { - ctx.log.info(`Configuring Apollo GraphOS registry reporting`); + ctx.log.info('Configuring Apollo GraphOS registry reporting'); if (!opts.apolloGraphRef?.includes('@')) { ctx.log.error( `Apollo GraphOS requires a graph ref in the format @. Please provide a valid graph ref ${opts.apolloGraphRef ? `not ${opts.apolloGraphRef}` : ''}.`, diff --git a/packages/gateway/src/commands/proxy.ts b/packages/gateway/src/commands/proxy.ts index 14bf10688..268b5f565 100644 --- a/packages/gateway/src/commands/proxy.ts +++ b/packages/gateway/src/commands/proxy.ts @@ -1,6 +1,7 @@ import cluster from 'node:cluster'; import { createGatewayRuntime, + createLoggerFromLogging, type GatewayConfigProxy, } from '@graphql-hive/gateway-runtime'; import { PubSub } from '@graphql-hive/pubsub'; @@ -18,7 +19,6 @@ import { } from '../config'; import { startServerForRuntime } from '../servers/startServerForRuntime'; import { handleFork } from './handleFork'; -import { handleLoggingConfig } from './handleLoggingOption'; import { handleReportingConfig } from './handleReportingConfig'; export const addCommand: AddCommand = (ctx, cli) => @@ -115,11 +115,11 @@ export const addCommand: AddCommand = (ctx, cli) => const pubsub = loadedConfig.pubsub || new PubSub(); const cwd = loadedConfig.cwd || process.cwd(); if (loadedConfig.logging != null) { - handleLoggingConfig(loadedConfig.logging, ctx); + ctx.log = createLoggerFromLogging(loadedConfig.logging); } const cache = await getCacheInstanceFromConfig(loadedConfig, { pubsub, - logger: ctx.log, + log: ctx.log, cwd, }); const builtinPlugins = await getBuiltinPluginsFromConfig( diff --git a/packages/gateway/src/commands/subgraph.ts b/packages/gateway/src/commands/subgraph.ts index 64b5cdf1c..8ccf1bfbe 100644 --- a/packages/gateway/src/commands/subgraph.ts +++ b/packages/gateway/src/commands/subgraph.ts @@ -3,6 +3,7 @@ import { lstat } from 'node:fs/promises'; import { isAbsolute, resolve } from 'node:path'; import { createGatewayRuntime, + createLoggerFromLogging, type GatewayConfigSubgraph, type UnifiedGraphConfig, } from '@graphql-hive/gateway-runtime'; @@ -22,7 +23,6 @@ import { } from '../config'; import { startServerForRuntime } from '../servers/startServerForRuntime'; import { handleFork } from './handleFork'; -import { handleLoggingConfig } from './handleLoggingOption'; import { handleReportingConfig } from './handleReportingConfig'; export const addCommand: AddCommand = (ctx, cli) => @@ -75,11 +75,11 @@ export const addCommand: AddCommand = (ctx, cli) => const pubsub = loadedConfig.pubsub || new PubSub(); const cwd = loadedConfig.cwd || process.cwd(); if (loadedConfig.logging != null) { - handleLoggingConfig(loadedConfig.logging, ctx); + ctx.log = createLoggerFromLogging(loadedConfig.logging); } const cache = await getCacheInstanceFromConfig(loadedConfig, { pubsub, - logger: ctx.log, + log: ctx.log, cwd, }); const builtinPlugins = await getBuiltinPluginsFromConfig( diff --git a/packages/gateway/src/commands/supergraph.ts b/packages/gateway/src/commands/supergraph.ts index 6c17f1364..7ef2e827f 100644 --- a/packages/gateway/src/commands/supergraph.ts +++ b/packages/gateway/src/commands/supergraph.ts @@ -4,6 +4,7 @@ import { isAbsolute, resolve } from 'node:path'; import { Option } from '@commander-js/extra-typings'; import { createGatewayRuntime, + createLoggerFromLogging, type GatewayConfigSupergraph, type GatewayGraphOSManagedFederationOptions, type GatewayHiveCDNOptions, @@ -28,7 +29,6 @@ import { } from '../config'; import { startServerForRuntime } from '../servers/startServerForRuntime'; import { handleFork } from './handleFork'; -import { handleLoggingConfig } from './handleLoggingOption'; import { handleReportingConfig } from './handleReportingConfig'; export const addCommand: AddCommand = (ctx, cli) => @@ -77,13 +77,13 @@ export const addCommand: AddCommand = (ctx, cli) => | GatewayHiveCDNOptions | GatewayGraphOSManagedFederationOptions = 'supergraph.graphql'; if (schemaPathOrUrl) { - ctx.log.info(`Supergraph will be loaded from ${schemaPathOrUrl}`); + ctx.log.info('Supergraph will be loaded from %s', schemaPathOrUrl); if (hiveCdnKey) { - ctx.log.info(`Using Hive CDN key`); + ctx.log.info('Using Hive CDN key'); if (!isUrl(schemaPathOrUrl)) { ctx.log.error( - 'Hive CDN endpoint must be a URL when providing --hive-cdn-key but got ' + - schemaPathOrUrl, + 'Hive CDN endpoint must be a URL when providing --hive-cdn-key but got %s', + schemaPathOrUrl, ); process.exit(1); } @@ -93,10 +93,11 @@ export const addCommand: AddCommand = (ctx, cli) => key: hiveCdnKey, }; } else if (apolloKey) { - ctx.log.info(`Using GraphOS API key`); + ctx.log.info('Using GraphOS API key'); if (!schemaPathOrUrl.includes('@')) { ctx.log.error( - `Apollo GraphOS requires a graph ref in the format @ when providing --apollo-key. Please provide a valid graph ref not ${schemaPathOrUrl}.`, + `Apollo GraphOS requires a graph ref in the format @ when providing --apollo-key. Please provide a valid graph ref not %s.`, + schemaPathOrUrl, ); process.exit(1); } @@ -123,7 +124,7 @@ export const addCommand: AddCommand = (ctx, cli) => ); process.exit(1); } - ctx.log.info(`Using Hive CDN endpoint: ${hiveCdnEndpoint}`); + ctx.log.info('Using Hive CDN endpoint %s', hiveCdnEndpoint); supergraph = { type: 'hive', endpoint: hiveCdnEndpoint, @@ -132,17 +133,18 @@ export const addCommand: AddCommand = (ctx, cli) => } else if (apolloGraphRef) { if (!apolloGraphRef.includes('@')) { ctx.log.error( - `Apollo GraphOS requires a graph ref in the format @. Please provide a valid graph ref not ${apolloGraphRef}.`, + 'Apollo GraphOS requires a graph ref in the format @. Please provide a valid graph ref not %s.', + apolloGraphRef, ); process.exit(1); } if (!apolloKey) { ctx.log.error( - `Apollo GraphOS requires an API key. Please provide an API key using the --apollo-key option.`, + 'Apollo GraphOS requires an API key. Please provide an API key using the --apollo-key option.', ); process.exit(1); } - ctx.log.info(`Using Apollo Graph Ref: ${apolloGraphRef}`); + ctx.log.info('Using Apollo Graph Ref %s', apolloGraphRef); supergraph = { type: 'graphos', apiKey: apolloKey, @@ -153,7 +155,7 @@ export const addCommand: AddCommand = (ctx, cli) => supergraph = loadedConfig.supergraph!; // TODO: assertion wont be necessary when exactOptionalPropertyTypes // TODO: how to provide hive-cdn-key? } else { - ctx.log.info(`Using default supergraph location: ${supergraph}`); + ctx.log.info('Using default supergraph location %s', supergraph); } const registryConfig: Pick = {}; @@ -171,11 +173,11 @@ export const addCommand: AddCommand = (ctx, cli) => const pubsub = loadedConfig.pubsub || new PubSub(); const cwd = loadedConfig.cwd || process.cwd(); if (loadedConfig.logging != null) { - handleLoggingConfig(loadedConfig.logging, ctx); + ctx.log = createLoggerFromLogging(loadedConfig.logging); } const cache = await getCacheInstanceFromConfig(loadedConfig, { pubsub, - logger: ctx.log, + log: ctx.log, cwd, }); const builtinPlugins = await getBuiltinPluginsFromConfig( @@ -224,7 +226,7 @@ export const addCommand: AddCommand = (ctx, cli) => loadedConfig.persistedDocuments.token); if (!token) { ctx.log.error( - `Hive persisted documents needs a CDN token. Please provide it through the "--hive-persisted-documents-token " option or the config.`, + 'Hive persisted documents needs a CDN token. Please provide it through the "--hive-persisted-documents-token " option or the config.', ); process.exit(1); } @@ -270,12 +272,13 @@ export async function runSupergraph( absSchemaPath = isAbsolute(supergraphPath) ? String(supergraphPath) : resolve(process.cwd(), supergraphPath); - log.info(`Reading supergraph from ${absSchemaPath}`); + log.info('Reading supergraph from %s', absSchemaPath); try { await lstat(absSchemaPath); } catch { log.error( - `Could not read supergraph from ${absSchemaPath}. Make sure the file exists.`, + 'Could not read supergraph from %s. Make sure the file exists.', + absSchemaPath, ); process.exit(1); } @@ -285,11 +288,11 @@ export async function runSupergraph( // Polling should not be enabled when watching the file delete config.pollingInterval; if (cluster.isPrimary) { - log.info(`Watching ${absSchemaPath} for changes`); + log.info('Watching %s for changes', absSchemaPath); const ctrl = new AbortController(); registerTerminateHandler((signal) => { - log.info(`Closing watcher for ${absSchemaPath} on ${signal}`); + log.info('Closing watcher for %s on %s', absSchemaPath, signal); return ctrl.abort(`Process terminated on ${signal}`); }); @@ -301,7 +304,7 @@ export async function runSupergraph( // TODO: or should we just ignore? throw new Error(`Supergraph file was renamed to "${f.filename}"`); } - log.info(`${absSchemaPath} changed. Invalidating supergraph...`); + log.info('%s changed. Invalidating supergraph...', absSchemaPath); if (config.fork && config.fork > 1) { for (const workerId in cluster.workers) { cluster.workers[workerId]!.send('invalidateUnifiedGraph'); @@ -314,10 +317,10 @@ export async function runSupergraph( })() .catch((e) => { if (e.name === 'AbortError') return; - log.error(`Watcher for ${absSchemaPath} closed with an error`, e); + log.error(e, 'Watcher for %s closed with an error', absSchemaPath); }) .then(() => { - log.info(`Watcher for ${absSchemaPath} successfuly closed`); + log.info('Watcher for %s successfuly closed', absSchemaPath); }); } } @@ -352,16 +355,17 @@ export async function runSupergraph( const runtime = createGatewayRuntime(config); if (absSchemaPath) { - log.info(`Serving local supergraph from ${absSchemaPath}`); + log.info('Serving local supergraph from %s', absSchemaPath); } else if (isUrl(String(config.supergraph))) { - log.info(`Serving remote supergraph from ${config.supergraph}`); + log.info('Serving remote supergraph from %s', config.supergraph); } else if ( typeof config.supergraph === 'object' && 'type' in config.supergraph && config.supergraph.type === 'hive' ) { log.info( - `Serving supergraph from Hive CDN at ${config.supergraph.endpoint}`, + 'Serving supergraph from Hive CDN at %s', + config.supergraph.endpoint, ); } else { log.info('Serving supergraph from config'); diff --git a/packages/gateway/src/config.ts b/packages/gateway/src/config.ts index 9de1b580c..ea87b3468 100644 --- a/packages/gateway/src/config.ts +++ b/packages/gateway/src/config.ts @@ -5,8 +5,9 @@ import type { GatewayConfig, GatewayPlugin, } from '@graphql-hive/gateway-runtime'; +import { LegacyLogger, type Logger } from '@graphql-hive/logger'; import { HivePubSub } from '@graphql-hive/pubsub'; -import type { KeyValueCache, Logger } from '@graphql-mesh/types'; +import type { KeyValueCache } from '@graphql-mesh/types'; import type { GatewayCLIBuiltinPluginConfig } from './cli'; import type { ServerConfig } from './servers/types'; @@ -204,7 +205,7 @@ export async function getBuiltinPluginsFromConfig( */ export async function getCacheInstanceFromConfig( config: GatewayCLIBuiltinPluginConfig, - ctx: { logger: Logger; pubsub: HivePubSub; cwd: string }, + ctx: { log: Logger; pubsub: HivePubSub; cwd: string }, ): Promise { if (typeof config.cache === 'function') { return config.cache(ctx); @@ -219,6 +220,8 @@ export async function getCacheInstanceFromConfig( return new RedisCache({ ...ctx, ...config.cache, + // TODO: use new logger + logger: LegacyLogger.from(ctx.log), }) as KeyValueCache; } case 'cfw-kv': { @@ -241,7 +244,7 @@ export async function getCacheInstanceFromConfig( } } if (config.cache.type !== 'localforage') { - ctx.logger.warn( + ctx.log.warn( 'Unknown cache type, falling back to localforage', config.cache, ); diff --git a/packages/gateway/src/index.ts b/packages/gateway/src/index.ts index 5a01d9d6d..f294a5326 100644 --- a/packages/gateway/src/index.ts +++ b/packages/gateway/src/index.ts @@ -1,6 +1,6 @@ export * from './cli'; +export * from '@graphql-hive/logger'; export * from '@graphql-hive/gateway-runtime'; -export { LogLevel, DefaultLogger } from '@graphql-mesh/utils'; export { PubSub } from '@graphql-hive/pubsub'; export * from '@graphql-mesh/plugin-jwt-auth'; export * from '@graphql-mesh/plugin-opentelemetry'; diff --git a/packages/gateway/src/servers/bun.ts b/packages/gateway/src/servers/bun.ts index 628838a67..85a4acf1e 100644 --- a/packages/gateway/src/servers/bun.ts +++ b/packages/gateway/src/servers/bun.ts @@ -64,6 +64,6 @@ export async function startBunServer>( }; } const server = Bun.serve(serverOptions); - opts.log.info(`Listening on ${server.url}`); + opts.log.info('Listening on %s', server.url); gwRuntime.disposableStack.use(server); } diff --git a/packages/gateway/src/servers/nodeHttp.ts b/packages/gateway/src/servers/nodeHttp.ts index ad6b7e48c..e54d96c3e 100644 --- a/packages/gateway/src/servers/nodeHttp.ts +++ b/packages/gateway/src/servers/nodeHttp.ts @@ -77,7 +77,7 @@ export async function startNodeHttpServer>( const url = `${protocol}://${host}:${port}`.replace('0.0.0.0', 'localhost'); - log.debug(`Starting server on ${url}`); + log.debug('Starting server on %s', url); if (!disableWebsockets) { log.debug('Setting up WebSocket server'); const { WebSocketServer } = await import('ws'); @@ -98,12 +98,12 @@ export async function startNodeHttpServer>( gwRuntime.disposableStack.defer( () => new Promise((resolve, reject) => { - log.info(`Stopping the WebSocket server`); + log.info('Stopping the WebSocket server'); wsServer.close((err) => { if (err) { return reject(err); } - log.info(`Stopped the WebSocket server successfully`); + log.info('Stopped the WebSocket server successfully'); return resolve(); }); }), @@ -112,15 +112,15 @@ export async function startNodeHttpServer>( return new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, host, () => { - log.info(`Listening on ${url}`); + log.info('Listening on %s', url); gwRuntime.disposableStack.defer( () => new Promise((resolve) => { process.stderr.write('\n'); - log.info(`Stopping the server`); + log.info('Stopping the server'); server.closeAllConnections(); server.close(() => { - log.info(`Stopped the server successfully`); + log.info('Stopped the server successfully'); return resolve(); }); }), diff --git a/packages/gateway/src/servers/startServerForRuntime.ts b/packages/gateway/src/servers/startServerForRuntime.ts index 6cd4e14a8..0826a0851 100644 --- a/packages/gateway/src/servers/startServerForRuntime.ts +++ b/packages/gateway/src/servers/startServerForRuntime.ts @@ -20,7 +20,7 @@ export function startServerForRuntime< ): MaybePromise { process.on('message', (message) => { if (message === 'invalidateUnifiedGraph') { - log.info(`Invalidating Supergraph`); + log.info('Invalidating Supergraph'); runtime.invalidateUnifiedGraph(); } }); diff --git a/packages/gateway/src/servers/types.ts b/packages/gateway/src/servers/types.ts index e29222e46..950901a53 100644 --- a/packages/gateway/src/servers/types.ts +++ b/packages/gateway/src/servers/types.ts @@ -1,4 +1,4 @@ -import { Logger } from '@graphql-mesh/types'; +import type { Logger } from '@graphql-hive/logger'; export interface ServerConfig { /** diff --git a/packages/plugins/hmac-upstream-signature/src/index.ts b/packages/plugins/hmac-upstream-signature/src/index.ts index 3682c43c5..7590c84f7 100644 --- a/packages/plugins/hmac-upstream-signature/src/index.ts +++ b/packages/plugins/hmac-upstream-signature/src/index.ts @@ -9,7 +9,6 @@ import { import type { FetchAPI, GraphQLParams, - YogaLogger, Plugin as YogaPlugin, } from 'graphql-yoga'; import jsonStableStringify from 'json-stable-stringify'; @@ -87,24 +86,18 @@ export function useHmacUpstreamSignature( let key$: MaybePromise; let fetchAPI: FetchAPI; let textEncoder: TextEncoder; - let yogaLogger: YogaLogger; return { onYogaInit({ yoga }) { fetchAPI = yoga.fetchAPI; - yogaLogger = yoga.logger; }, - onSubgraphExecute({ - subgraphName, - subgraph, - executionRequest, - logger = yogaLogger, - }) { - logger?.debug(`running shouldSign for subgraph ${subgraphName}`); + onSubgraphExecute({ subgraphName, subgraph, executionRequest, log }) { + log.debug('Running shouldSign for subgraph %s', subgraphName); if (shouldSign({ subgraphName, subgraph, executionRequest })) { - logger?.debug( - `shouldSign is true for subgraph ${subgraphName}, signing request`, + log.debug( + 'shouldSign is true for subgraph $s, signing request', + subgraphName, ); textEncoder ||= new fetchAPI.TextEncoder(); return handleMaybePromise( @@ -128,7 +121,7 @@ export function useHmacUpstreamSignature( const extensionValue = fetchAPI.btoa( String.fromCharCode(...new Uint8Array(signature)), ); - logger?.debug( + log.debug( `produced hmac signature for subgraph ${subgraphName}, signature: ${extensionValue}, signed payload: ${serializedExecutionRequest}`, ); @@ -141,7 +134,7 @@ export function useHmacUpstreamSignature( }, ); } else { - logger?.debug( + log.debug( `shouldSign is false for subgraph ${subgraphName}, skipping hmac signature`, ); } @@ -167,14 +160,10 @@ export function useHmacSignatureValidation( const extensionName = options.extensionName || DEFAULT_EXTENSION_NAME; let key$: MaybePromise; let textEncoder: TextEncoder; - let logger: YogaLogger; const paramsSerializer = options.serializeParams || defaultParamsSerializer; return { - onYogaInit({ yoga }) { - logger = yoga.logger; - }, - onParams({ params, fetchAPI }) { + onParams({ params, fetchAPI, context }) { textEncoder ||= new fetchAPI.TextEncoder(); const extension = params.extensions?.[extensionName]; diff --git a/packages/plugins/prometheus/package.json b/packages/plugins/prometheus/package.json index 0a0239229..036bb1d7a 100644 --- a/packages/plugins/prometheus/package.json +++ b/packages/plugins/prometheus/package.json @@ -44,6 +44,7 @@ }, "dependencies": { "@graphql-hive/gateway-runtime": "workspace:^", + "@graphql-hive/logger": "workspace:^", "@graphql-mesh/cross-helpers": "^0.4.10", "@graphql-mesh/types": "^0.104.0", "@graphql-mesh/utils": "^0.104.2", diff --git a/packages/plugins/prometheus/src/index.ts b/packages/plugins/prometheus/src/index.ts index 00e2aa24b..11504023e 100644 --- a/packages/plugins/prometheus/src/index.ts +++ b/packages/plugins/prometheus/src/index.ts @@ -1,9 +1,9 @@ import { type GatewayPlugin } from '@graphql-hive/gateway-runtime'; +import type { Logger } from '@graphql-hive/logger'; import type { OnSubgraphExecuteHook } from '@graphql-mesh/fusion-runtime'; import type { TransportEntry } from '@graphql-mesh/transport-common'; import type { ImportFn, - Logger, MeshFetchRequestInit, MeshPlugin, OnFetchHook, @@ -140,12 +140,11 @@ type MeshMetricsConfig = { */ fetchResponseHeaders?: boolean | string[]; }; - /** * The logger instance used by the plugin to log messages. * This should be the logger instance provided by Mesh in the plugins context. */ - logger: Logger; + log: Logger; }; export type PrometheusPluginOptions = PrometheusTracingPluginConfig & @@ -369,7 +368,7 @@ export default function useMeshPrometheus( } function registryFromYamlConfig( - config: YamlConfig & { logger: Logger }, + config: YamlConfig & { log: Logger }, ): Registry { if (!config.registry) { throw new Error('Registry not defined in the YAML config'); @@ -393,7 +392,7 @@ function registryFromYamlConfig( registry$ .then(() => registryProxy.revoke()) - .catch((e) => config.logger.error(e)); + .catch((e) => config.log.error(e, 'Failed to load Prometheus registry')); return registryProxy.proxy; } diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 719daddba..ee4351f17 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -48,6 +48,7 @@ "@envelop/disable-introspection": "^7.0.0", "@envelop/generic-auth": "^9.0.0", "@graphql-hive/core": "^0.12.0", + "@graphql-hive/logger": "workspace:^", "@graphql-hive/logger-json": "workspace:^", "@graphql-hive/pubsub": "workspace:^", "@graphql-hive/signal": "workspace:^", diff --git a/packages/runtime/src/createGatewayRuntime.ts b/packages/runtime/src/createGatewayRuntime.ts index e45a06ea6..c4a1b5708 100644 --- a/packages/runtime/src/createGatewayRuntime.ts +++ b/packages/runtime/src/createGatewayRuntime.ts @@ -38,7 +38,6 @@ import { getInContextSDK, isDisposable, isUrl, - LogLevel, wrapFetchWithHooks, } from '@graphql-mesh/utils'; import { batchDelegateToSchema } from '@graphql-tools/batch-delegate'; @@ -73,6 +72,7 @@ import { isSchema, parse, } from 'graphql'; +import type { GraphiQLOptions, PromiseOrValue } from 'graphql-yoga'; import { chain, createYoga, @@ -83,9 +83,9 @@ import { type LandingPageRenderer, type YogaServerInstance, } from 'graphql-yoga'; -import type { GraphiQLOptions, PromiseOrValue } from 'graphql-yoga'; +import { LegacyLogger } from '../../logger/src/LegacyLogger'; +import { createLoggerFromLogging } from './createLoggerFromLogging'; import { createGraphOSFetcher } from './fetchers/graphos'; -import { handleLoggingConfig } from './getDefaultLogger'; import { getProxyExecutor } from './getProxyExecutor'; import { getReportingPlugin } from './getReportingPlugin'; import { @@ -151,7 +151,7 @@ export function createGatewayRuntime< TContext extends Record = Record, >(config: GatewayConfig): GatewayRuntime { let fetchAPI = config.fetchAPI; - const logger = handleLoggingConfig(config.logging); + const log = createLoggerFromLogging(config.logging); let instrumentation: GatewayPlugin['instrumentation']; @@ -162,7 +162,7 @@ export function createGatewayRuntime< const wrappedFetchFn = wrapFetchWithHooks( onFetchHooks, () => instrumentation, - logger, + LegacyLogger.from(log), ); const wrappedCache: KeyValueCache | undefined = config.cache ? wrapCacheWithHooks({ @@ -177,7 +177,7 @@ export function createGatewayRuntime< const configContext: GatewayConfigContext = { fetch: wrappedFetchFn, - logger, + log, cwd: config.cwd || (typeof process !== 'undefined' ? process.cwd() : ''), cache: wrappedCache, pubsub, @@ -217,7 +217,7 @@ export function createGatewayRuntime< persistedDocumentsPlugin = useHiveConsole({ ...configContext, enabled: false, // disables only usage reporting - logger: configContext.logger.child({ + log: configContext.log.child({ plugin: 'Hive Persisted Documents', }), experimental__persistedDocuments: { @@ -284,7 +284,9 @@ export function createGatewayRuntime< const fetcher = createSchemaFetcher({ endpoint, key, - logger: configContext.logger.child({ source: 'Hive CDN' }), + logger: LegacyLogger.from( + configContext.log.child({ source: 'Hive CDN' }), + ), }); schemaFetcher = function fetchSchemaFromCDN() { pausePolling(); @@ -353,7 +355,7 @@ export function createGatewayRuntime< return true; }, (err) => { - configContext.logger.warn(`Failed to introspect schema`, err); + configContext.log.warn(`Failed to introspect schema`, err); return true; }, ); @@ -639,7 +641,7 @@ export function createGatewayRuntime< unifiedGraph, // @ts-expect-error - Typings are wrong in legacy Mesh [subschemaConfig], - configContext.logger, + configContext.log, onDelegateHooks, ), ); @@ -673,7 +675,7 @@ export function createGatewayRuntime< const fetcher = createSupergraphSDLFetcher({ endpoint, key, - logger: configContext.logger.child({ source: 'Hive CDN' }), + log: configContext.log.child({ source: 'Hive CDN' }), // @ts-expect-error - MeshFetch is not compatible with `typeof fetch` fetchImplementation: configContext.fetch, }); @@ -698,10 +700,10 @@ export function createGatewayRuntime< // local or remote if (!isDynamicUnifiedGraphSchema(config.supergraph)) { // no polling for static schemas - logger.debug(`Disabling polling for static supergraph`); + log.debug(`Disabling polling for static supergraph`); delete config.pollingInterval; } else if (!config.pollingInterval) { - logger.debug( + log.debug( `Polling interval not set for supergraph, if you want to get updates of supergraph, we recommend setting a polling interval`, ); } @@ -748,24 +750,22 @@ export function createGatewayRuntime< }); getSchema = () => unifiedGraphManager.getUnifiedGraph(); readinessChecker = () => { - const logger = configContext.logger.child('readiness'); - logger.debug(`checking`); + const log = configContext.log.child('readiness'); + log.debug('checking'); return handleMaybePromise( () => unifiedGraphManager.getUnifiedGraph(), (schema) => { if (!schema) { - logger.debug( - `failed because supergraph has not been loaded yet or failed to load`, + log.debug( + 'failed because supergraph has not been loaded yet or failed to load', ); return false; } - logger.debug('passed'); + log.debug('passed'); return true; }, (err) => { - logger.error( - `failed due to errors on loading supergraph:\n${err.stack || err.message}`, - ); + log.error(err, 'loading supergraph failed due to errors'); return false; }, ); @@ -1024,7 +1024,7 @@ export function createGatewayRuntime< readinessCheckPlugin, registryPlugin, persistedDocumentsPlugin, - useRetryOnSchemaReload({ logger }), + useRetryOnSchemaReload(), ]; if (config.subgraphErrors !== false) { @@ -1153,12 +1153,11 @@ export function createGatewayRuntime< let isDebug: boolean = false; - if ('level' in logger) { - if (logger.level === 'debug' || logger.level === LogLevel.debug) { - isDebug = true; - } + if (config.logging === 'debug') { + isDebug = true; } else { - logger.debug(() => { + // TODO: adding extra plugins in a logger is not a good idea, what if the writer is async? refactor + log.debug(() => { isDebug = true; return 'Debug mode enabled'; }); @@ -1166,10 +1165,10 @@ export function createGatewayRuntime< if (isDebug) { extraPlugins.push( - useSubgraphExecuteDebug(configContext), - useFetchDebug(configContext), - useDelegationPlanDebug(configContext), - useCacheDebug(configContext), + useSubgraphExecuteDebug(), + useFetchDebug(), + useDelegationPlanDebug(), + useCacheDebug(), ); } @@ -1178,7 +1177,7 @@ export function createGatewayRuntime< schema: unifiedGraph, // @ts-expect-error MeshFetch is not compatible with YogaFetch fetchAPI: config.fetchAPI, - logging: logger, + logging: log, plugins: [ ...basePlugins, ...extraPlugins, diff --git a/packages/runtime/src/createLoggerFromLogging.ts b/packages/runtime/src/createLoggerFromLogging.ts new file mode 100644 index 000000000..2460e956a --- /dev/null +++ b/packages/runtime/src/createLoggerFromLogging.ts @@ -0,0 +1,13 @@ +import { Logger, LogLevel } from '@graphql-hive/logger'; + +export function createLoggerFromLogging( + logging: boolean | Logger | LogLevel | undefined, +) { + if (logging == null || typeof logging === 'boolean') { + return new Logger({ level: logging === false ? false : 'info' }); + } + if (typeof logging === 'string') { + return new Logger({ level: logging }); + } + return logging; +} diff --git a/packages/runtime/src/getDefaultLogger.ts b/packages/runtime/src/getDefaultLogger.ts deleted file mode 100644 index 037f779b6..000000000 --- a/packages/runtime/src/getDefaultLogger.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { JSONLogger } from '@graphql-hive/logger-json'; -import { process } from '@graphql-mesh/cross-helpers'; -import { Logger } from '@graphql-mesh/types'; -import { DefaultLogger, LogLevel } from '@graphql-mesh/utils'; - -export function getDefaultLogger(opts?: { name?: string; level?: LogLevel }) { - const logFormat = process.env['LOG_FORMAT'] || (globalThis as any).LOG_FORMAT; - if (logFormat) { - if (logFormat.toLowerCase() === 'json') { - return new JSONLogger(opts); - } else if (logFormat.toLowerCase() === 'pretty') { - return new DefaultLogger(opts?.name, opts?.level); - } - } - const nodeEnv = process.env['NODE_ENV'] || (globalThis as any).NODE_ENV; - if (nodeEnv === 'production') { - return new JSONLogger(opts); - } - return new DefaultLogger(opts?.name, opts?.level); -} - -export function handleLoggingConfig( - loggingConfig: - | boolean - | Logger - | LogLevel - | keyof typeof LogLevel - | undefined, - existingLogger?: Logger, -) { - if (typeof loggingConfig === 'object') { - return loggingConfig; - } - if (typeof loggingConfig === 'boolean') { - if (!loggingConfig) { - if (existingLogger && 'logLevel' in existingLogger) { - existingLogger.logLevel = LogLevel.silent; - return existingLogger; - } - return getDefaultLogger({ - name: existingLogger?.name, - level: LogLevel.silent, - }); - } - } - if (typeof loggingConfig === 'number') { - if (existingLogger && 'logLevel' in existingLogger) { - existingLogger.logLevel = loggingConfig; - return existingLogger; - } - return getDefaultLogger({ - name: existingLogger?.name, - level: loggingConfig, - }); - } - if (typeof loggingConfig === 'string') { - if (existingLogger && 'logLevel' in existingLogger) { - existingLogger.logLevel = LogLevel[loggingConfig]; - return existingLogger; - } - return getDefaultLogger({ - name: existingLogger?.name, - level: LogLevel[loggingConfig], - }); - } - return existingLogger || getDefaultLogger(); -} diff --git a/packages/runtime/src/getReportingPlugin.ts b/packages/runtime/src/getReportingPlugin.ts index 367a3bed8..e1bd85ba8 100644 --- a/packages/runtime/src/getReportingPlugin.ts +++ b/packages/runtime/src/getReportingPlugin.ts @@ -30,7 +30,7 @@ export function getReportingPlugin>( return { name: 'Hive', plugin: useHiveConsole({ - logger: configContext.logger.child({ reporting: 'Hive' }), + log: configContext.log.child('Reporting with Hive'), enabled: true, ...reporting, ...(usage ? { usage } : {}), diff --git a/packages/runtime/src/handleUnifiedGraphConfig.ts b/packages/runtime/src/handleUnifiedGraphConfig.ts index 40bb3249f..fb39c7788 100644 --- a/packages/runtime/src/handleUnifiedGraphConfig.ts +++ b/packages/runtime/src/handleUnifiedGraphConfig.ts @@ -1,3 +1,4 @@ +import { LegacyLogger } from '@graphql-hive/logger'; import { UnifiedGraphManagerOptions } from '@graphql-mesh/fusion-runtime'; import { defaultImportFn, isUrl, readFileOrUrl } from '@graphql-mesh/utils'; import { defaultPrintFn } from '@graphql-tools/executor-common'; @@ -51,7 +52,7 @@ export function handleUnifiedGraphSchema( return readFileOrUrl(unifiedGraphSchema, { fetch: configContext.fetch, cwd: configContext.cwd, - logger: configContext.logger, + logger: LegacyLogger.from(configContext.log), allowUnknownExtensions: true, importFn: defaultImportFn, }); diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index b110fa61a..fe34d3970 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -1,7 +1,7 @@ export * from './createGatewayRuntime'; -export { LogLevel, DefaultLogger } from '@graphql-mesh/utils'; -export { JSONLogger } from '@graphql-hive/logger-json'; export type * from './types'; +export * from '@graphql-hive/logger'; +export * from './createLoggerFromLogging'; export * from './plugins/useCustomFetch'; export * from './plugins/useStaticFiles'; export * from './getProxyExecutor'; @@ -20,4 +20,3 @@ export { } from './plugins/useUpstreamRetry'; export { useUpstreamTimeout } from './plugins/useUpstreamTimeout'; export { getGraphQLWSOptions } from './getGraphQLWSOptions'; -export * from './getDefaultLogger'; diff --git a/packages/runtime/src/plugins/useCacheDebug.ts b/packages/runtime/src/plugins/useCacheDebug.ts index a07cdc7b2..313ab83b0 100644 --- a/packages/runtime/src/plugins/useCacheDebug.ts +++ b/packages/runtime/src/plugins/useCacheDebug.ts @@ -4,7 +4,13 @@ import { GatewayPlugin } from '../types'; export function useCacheDebug< TContext extends Record, >(): GatewayPlugin { + let log: Logger; return { + onContextBuilding({ context }) { + // TODO: this one should execute last + // TODO: on contextBuilding might not execute at all + log = context.log; + }, onCacheGet({ key }) { log = log.child({ key }); log.debug('cache get'); diff --git a/packages/runtime/src/plugins/useDelegationPlanDebug.ts b/packages/runtime/src/plugins/useDelegationPlanDebug.ts index eb792a9fa..477b84243 100644 --- a/packages/runtime/src/plugins/useDelegationPlanDebug.ts +++ b/packages/runtime/src/plugins/useDelegationPlanDebug.ts @@ -1,4 +1,3 @@ -import type { Logger } from '@graphql-mesh/types'; import { pathToArray } from '@graphql-tools/utils'; import { print } from 'graphql'; import { FetchAPI } from 'graphql-yoga'; @@ -6,7 +5,7 @@ import type { GatewayContext, GatewayPlugin } from '../types'; export function useDelegationPlanDebug< TContext extends Record, ->(opts: { logger: Logger }): GatewayPlugin { +>(): GatewayPlugin { let fetchAPI: FetchAPI; const stageExecuteLogById = new WeakMap>(); return { @@ -18,15 +17,12 @@ export function useDelegationPlanDebug< variables, fragments, fieldNodes, + context, info, - logger = opts.logger, }) { const planId = fetchAPI.crypto.randomUUID(); - const planLogger = logger.child({ planId, typeName }); - const delegationPlanStartLogger = planLogger.child( - 'delegation-plan-start', - ); - delegationPlanStartLogger.debug(() => { + const log = context.log.child({ planId, typeName }); + log.debug(() => { const logObj: Record = {}; if (variables && Object.keys(variables).length) { logObj['variables'] = variables; @@ -48,19 +44,20 @@ export function useDelegationPlanDebug< logObj['path'] = pathToArray(info.path).join(' | '); } return logObj; - }); + }, 'delegation-plan-start'); return ({ delegationPlan }) => { - const delegationPlanDoneLogger = logger.child('delegation-plan-done'); - delegationPlanDoneLogger.debug(() => - delegationPlan.map((plan) => { - const planObj: Record = {}; - for (const [subschema, selectionSet] of plan) { - if (subschema.name) { - planObj[subschema.name] = print(selectionSet); + log.debug( + () => + delegationPlan.map((plan) => { + const planObj: Record = {}; + for (const [subschema, selectionSet] of plan) { + if (subschema.name) { + planObj[subschema.name] = print(selectionSet); + } } - } - return planObj; - }), + return planObj; + }), + 'delegation-plan-done', ); }; }, @@ -72,19 +69,18 @@ export function useDelegationPlanDebug< selectionSet, key, typeName, - logger = opts.logger, }) { let contextLog = stageExecuteLogById.get(context); if (!contextLog) { contextLog = new Set(); stageExecuteLogById.set(context, contextLog); } - const log = { + const logAttr = { key: JSON.stringify(key), object: JSON.stringify(object), selectionSet: print(selectionSet), }; - const logStr = JSON.stringify(log); + const logStr = JSON.stringify(logAttr); if (contextLog.has(logStr)) { return; } @@ -94,18 +90,16 @@ export function useDelegationPlanDebug< subgraph, typeName, }; - const delegationStageLogger = logger.child(logMeta); - delegationStageLogger.debug('delegation-plan-start', () => { - return { + const log = context.log.child(logMeta); + log.debug( + () => ({ ...log, path: pathToArray(info.path).join(' | '), - }; - }); + }), + 'delegation-plan-start', + ); return ({ result }) => { - const delegationStageExecuteDoneLogger = logger.child( - 'delegation-stage-execute-done', - ); - delegationStageExecuteDoneLogger.debug(() => result); + log.debug(() => result, 'delegation-stage-execute-done'); }; }, }; diff --git a/packages/runtime/src/plugins/useDemandControl.ts b/packages/runtime/src/plugins/useDemandControl.ts index e55adf7e5..b8586e5de 100644 --- a/packages/runtime/src/plugins/useDemandControl.ts +++ b/packages/runtime/src/plugins/useDemandControl.ts @@ -69,8 +69,7 @@ export function useDemandControl>({ }); const costByContextMap = new WeakMap(); return { - onSubgraphExecute({ subgraph, executionRequest, logger }) { - const demandControlLogger = logger?.child('demand-control'); + onSubgraphExecute({ subgraph, executionRequest, log }) { let costByContext = executionRequest.context ? costByContextMap.get(executionRequest.context) || 0 : 0; @@ -83,10 +82,13 @@ export function useDemandControl>({ if (executionRequest.context) { costByContextMap.set(executionRequest.context, costByContext); } - demandControlLogger?.debug({ - operationCost, - totalCost: costByContext, - }); + log.debug( + { + operationCost, + totalCost: costByContext, + }, + 'demand-control', + ); if (maxCost != null && costByContext > maxCost) { throw createGraphQLError( `Operation estimated cost ${costByContext} exceeded configured maximum ${maxCost}`, diff --git a/packages/runtime/src/plugins/useHiveConsole.ts b/packages/runtime/src/plugins/useHiveConsole.ts index 4f34a7c9a..9931077dc 100644 --- a/packages/runtime/src/plugins/useHiveConsole.ts +++ b/packages/runtime/src/plugins/useHiveConsole.ts @@ -1,7 +1,7 @@ import type { HivePluginOptions } from '@graphql-hive/core'; +import type { Logger } from '@graphql-hive/logger'; import { useHive } from '@graphql-hive/yoga'; import { process } from '@graphql-mesh/cross-helpers'; -import type { Logger } from '@graphql-mesh/types'; import { GatewayPlugin } from '../types'; export interface HiveConsolePluginOptions @@ -33,13 +33,13 @@ export default function useHiveConsole< enabled, token, ...options -}: HiveConsolePluginOptions & { logger: Logger }): GatewayPlugin< +}: HiveConsolePluginOptions & { log: Logger }): GatewayPlugin< TPluginContext, TContext > { const agent: HiveConsolePluginOptions['agent'] = { name: 'hive-gateway', - logger: options.logger, + logger: 'TODO', ...options.agent, }; diff --git a/packages/runtime/src/plugins/useRequestId.ts b/packages/runtime/src/plugins/useRequestId.ts index 46468012b..07fea8911 100644 --- a/packages/runtime/src/plugins/useRequestId.ts +++ b/packages/runtime/src/plugins/useRequestId.ts @@ -48,12 +48,14 @@ export function useRequestId>( }); requestIdByRequest.set(request, requestId); }, - onContextBuilding({ context }) { + onContextBuilding({ context, extendContext }) { if (context?.request) { const requestId = requestIdByRequest.get(context.request); - if (requestId && context.logger) { - // @ts-expect-error - Logger is somehow read-only - context.logger = context.logger.child({ requestId }); + if (requestId) { + extendContext( + // @ts-expect-error TODO: typescript is acting up here + { log: context.log.child({ requestId }) }, + ); } } }, diff --git a/packages/runtime/src/plugins/useRetryOnSchemaReload.ts b/packages/runtime/src/plugins/useRetryOnSchemaReload.ts index f0f5060a1..da0256020 100644 --- a/packages/runtime/src/plugins/useRetryOnSchemaReload.ts +++ b/packages/runtime/src/plugins/useRetryOnSchemaReload.ts @@ -1,4 +1,4 @@ -import { Logger } from '@graphql-mesh/types'; +import type { Logger } from '@graphql-hive/logger'; import { requestIdByRequest } from '@graphql-mesh/utils'; import type { MaybeAsyncIterable } from '@graphql-tools/utils'; import { @@ -11,11 +11,9 @@ import type { GatewayPlugin } from '../types'; type ExecHandler = () => MaybePromise>; -export function useRetryOnSchemaReload>({ - logger, -}: { - logger: Logger; -}): GatewayPlugin { +export function useRetryOnSchemaReload< + TContext extends Record, +>(): GatewayPlugin { const execHandlerByContext = new WeakMap<{}, ExecHandler>(); function handleOnExecute(args: ExecutionArgs) { if (args.contextValue) { @@ -32,7 +30,7 @@ export function useRetryOnSchemaReload>({ setResult, request, }: { - context: {}; + context: { log: Logger }; result?: ExecutionResult; setResult: (result: MaybeAsyncIterable) => void; request: Request; @@ -42,12 +40,12 @@ export function useRetryOnSchemaReload>({ execHandler && result?.errors?.some((e) => e.extensions?.['code'] === 'SCHEMA_RELOAD') ) { - let requestLogger = logger; + let log = context.log; const requestId = requestIdByRequest.get(request); if (requestId) { - requestLogger = logger.child({ requestId }); + log = log.child({ requestId }); } - requestLogger.info( + log.info( 'The operation has been aborted after the supergraph schema reloaded, retrying the operation...', ); if (execHandler) { diff --git a/packages/runtime/src/plugins/useWebhooks.ts b/packages/runtime/src/plugins/useWebhooks.ts index 8f557e5ec..63175f246 100644 --- a/packages/runtime/src/plugins/useWebhooks.ts +++ b/packages/runtime/src/plugins/useWebhooks.ts @@ -2,15 +2,15 @@ import { HivePubSub } from '@graphql-hive/pubsub'; import type { Logger } from '@graphql-mesh/types'; import { handleMaybePromise } from '@whatwg-node/promise-helpers'; import type { Plugin } from 'graphql-yoga'; +import { GatewayPlugin } from '../types'; export interface GatewayWebhooksPluginOptions { pubsub?: HivePubSub; - logger: Logger; } + export function useWebhooks({ pubsub, - logger, -}: GatewayWebhooksPluginOptions): Plugin { +}: GatewayWebhooksPluginOptions): GatewayPlugin { if (!pubsub) { throw new Error(`You must provide a pubsub instance to webhooks feature! Example: diff --git a/packages/runtime/src/types.ts b/packages/runtime/src/types.ts index ebda1cc81..71c4c6c53 100644 --- a/packages/runtime/src/types.ts +++ b/packages/runtime/src/types.ts @@ -1,5 +1,6 @@ import type { Plugin as EnvelopPlugin } from '@envelop/core'; import type { useGenericAuth } from '@envelop/generic-auth'; +import type { Logger, LogLevel } from '@graphql-hive/logger'; import { HivePubSub } from '@graphql-hive/pubsub'; import type { Instrumentation as GatewayRuntimeInstrumentation, @@ -11,11 +12,10 @@ import type { HMACUpstreamSignatureOptions } from '@graphql-mesh/hmac-upstream-s import type { ResponseCacheConfig } from '@graphql-mesh/plugin-response-cache'; import type { KeyValueCache, - Logger, MeshFetch, OnFetchHook, } from '@graphql-mesh/types'; -import type { FetchInstrumentation, LogLevel } from '@graphql-mesh/utils'; +import type { FetchInstrumentation } from '@graphql-mesh/utils'; import type { HTTPExecutorOptions } from '@graphql-tools/executor-http'; import type { IResolvers, @@ -61,9 +61,9 @@ export interface GatewayConfigContext { */ fetch: MeshFetch; /** - * The logger to use throught Mesh and it's plugins. + * The logger to use throught Hive and its plugins. */ - logger: Logger; + log: Logger; /** * Current working directory. */ @@ -117,6 +117,7 @@ export type OnCacheGetHook = ( ) => MaybePromise; export interface OnCacheGetHookEventPayload { + log: Logger; cache: KeyValueCache; key: string; ttl?: number; @@ -131,11 +132,13 @@ export interface OnCacheGetHookResult { export type OnCacheErrorHook = (payload: OnCacheErrorHookPayload) => void; export interface OnCacheErrorHookPayload { + log: Logger; error: Error; } export type OnCacheHitHook = (payload: OnCacheHitHookEventPayload) => void; export interface OnCacheHitHookEventPayload { + log: Logger; value: any; } export type OnCacheMissHook = () => void; @@ -150,6 +153,7 @@ export interface OnCacheSetHookResult { } export interface OnCacheSetHookEventPayload { + log: Logger; cache: KeyValueCache; key: string; value: any; @@ -166,6 +170,7 @@ export interface OnCacheDeleteHookResult { } export interface OnCacheDeleteHookEventPayload { + log: Logger; cache: KeyValueCache; key: string; } @@ -481,9 +486,10 @@ interface GatewayConfigBase> { * Enable, disable or implement a custom logger for logging. * * @default true + * * @see https://the-guild.dev/graphql/hive/docs/gateway/logging-and-error-handling */ - logging?: boolean | Logger | LogLevel | keyof typeof LogLevel | undefined; + logging?: boolean | Logger | LogLevel | undefined; /** * Endpoint of the GraphQL API. */ diff --git a/packages/transports/common/package.json b/packages/transports/common/package.json index 6322bb0b0..61e3c0848 100644 --- a/packages/transports/common/package.json +++ b/packages/transports/common/package.json @@ -43,6 +43,7 @@ }, "dependencies": { "@envelop/core": "^5.2.3", + "@graphql-hive/logger": "workspace:^", "@graphql-hive/pubsub": "workspace:^", "@graphql-hive/signal": "workspace:^", "@graphql-mesh/types": "^0.104.0", diff --git a/packages/transports/common/src/types.ts b/packages/transports/common/src/types.ts index 529ca9828..506ac1016 100644 --- a/packages/transports/common/src/types.ts +++ b/packages/transports/common/src/types.ts @@ -1,5 +1,6 @@ +import type { Logger } from '@graphql-hive/logger'; import { HivePubSub } from '@graphql-hive/pubsub'; -import type { KeyValueCache, Logger, MeshFetch } from '@graphql-mesh/types'; +import type { KeyValueCache, MeshFetch } from '@graphql-mesh/types'; import type { Executor, MaybePromise } from '@graphql-tools/utils'; import type { GraphQLError, GraphQLSchema } from 'graphql'; @@ -20,10 +21,12 @@ export interface TransportEntry< } export interface TransportContext { + log: Logger; + /** The fetch API to use. */ fetch?: MeshFetch; - pubsub?: HivePubSub; - logger?: Logger; + /** Will be empty when run on serverless. */ cwd?: string; + pubsub?: HivePubSub; cache?: KeyValueCache; } diff --git a/packages/transports/http-callback/src/index.ts b/packages/transports/http-callback/src/index.ts index 614670d67..69ec88a76 100644 --- a/packages/transports/http-callback/src/index.ts +++ b/packages/transports/http-callback/src/index.ts @@ -73,7 +73,7 @@ export default { transportEntry, fetch, pubsub, - logger, + log, }): DisposableExecutor { let headersInConfig: Record | undefined; if (typeof transportEntry.headers === 'string') { @@ -106,7 +106,7 @@ export default { executionRequest: ExecutionRequest, ) { const subscriptionId = crypto.randomUUID(); - const subscriptionLogger = logger?.child({ + log = log.child({ executor: 'http-callback', subscription: subscriptionId, }); @@ -138,8 +138,10 @@ export default { stopSubscription(createTimeoutError()); }, heartbeatIntervalMs), ); - subscriptionLogger?.debug( - `Subscribing to ${transportEntry.location} with callbackUrl: ${callbackUrl}`, + log.debug( + 'Subscribing to %s with callbackUrl: %s', + transportEntry.location, + callbackUrl, ); let pushFn: Push = () => { throw new Error( @@ -202,7 +204,7 @@ export default { } return; } - logger?.debug(`Subscription request received`, resJson); + log.debug(resJson, 'Subscription request received'); if (resJson.errors) { if (resJson.errors.length === 1 && resJson.errors[0]) { const error = resJson.errors[0]; @@ -224,7 +226,7 @@ export default { }, ), (e) => { - logger?.debug(`Subscription request failed`, e); + log.debug(e, `Subscription request failed`); stopSubscription(e); }, ); @@ -246,13 +248,14 @@ export default { pushFn = push; stopSubscription = stop; stopFnSet.add(stop); - logger?.debug(`Listening to ${subscriptionCallbackPath}`); + log.debug('Listening to %s', subscriptionCallbackPath); const subId = pubsub.subscribe( `webhook:post:${subscriptionCallbackPath}`, (message: HTTPCallbackMessage) => { - logger?.debug( - `Received message from ${subscriptionCallbackPath}`, + log.debug( message, + 'Received message from %s', + subscriptionCallbackPath, ); if (message.verifier !== verifier) { return; diff --git a/packages/transports/ws/src/index.ts b/packages/transports/ws/src/index.ts index 2f937b45a..0ab2ccf7b 100644 --- a/packages/transports/ws/src/index.ts +++ b/packages/transports/ws/src/index.ts @@ -33,7 +33,7 @@ export interface WSTransportOptions { export default { getSubgraphExecutor( - { transportEntry, logger }, + { transportEntry, log }, /** * Do not use this option unless you know what you are doing. * @internal @@ -76,12 +76,12 @@ export default { let wsExecutor = wsExecutorMap.get(hash); if (!wsExecutor) { - const executorLogger = logger?.child({ + log = log.child({ executor: 'GraphQL WS', wsUrl, connectionParams, headers, - } as Record); + }); wsExecutor = buildGraphQLWSExecutor({ headers, url: wsUrl, @@ -91,30 +91,30 @@ export default { connectionParams, on: { connecting(isRetry) { - executorLogger?.debug('connecting', { isRetry }); + log.debug({ isRetry }, 'connecting'); }, opened(socket) { - executorLogger?.debug('opened', { socket }); + log.debug({ socket }, 'opened'); }, connected(socket, payload) { - executorLogger?.debug('connected', { socket, payload }); + log.debug({ socket, payload }, 'connected'); }, ping(received, payload) { - executorLogger?.debug('ping', { received, payload }); + log.debug({ received, payload }, 'ping'); }, pong(received, payload) { - executorLogger?.debug('pong', { received, payload }); + log.debug({ received, payload }, 'pong'); }, message(message) { - executorLogger?.debug('message', { message }); + log.debug({ message }, 'message'); }, closed(event) { - executorLogger?.debug('closed', { event }); + log.debug({ event }, 'closed'); // no subscriptions and the lazy close timeout has passed - remove the client wsExecutorMap.delete(hash); }, error(error) { - executorLogger?.debug('error', { error }); + log.debug({ error }, 'error'); }, }, onClient, diff --git a/yarn.lock b/yarn.lock index eeb85b32f..db2feb0b4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3942,6 +3942,7 @@ __metadata: "@envelop/disable-introspection": "npm:^7.0.0" "@envelop/generic-auth": "npm:^9.0.0" "@graphql-hive/core": "npm:^0.12.0" + "@graphql-hive/logger": "workspace:^" "@graphql-hive/logger-json": "workspace:^" "@graphql-hive/pubsub": "workspace:^" "@graphql-hive/signal": "workspace:^" @@ -4000,6 +4001,7 @@ __metadata: "@escape.tech/graphql-armor-max-tokens": "npm:^2.5.0" "@graphql-hive/gateway-runtime": "workspace:^" "@graphql-hive/importer": "workspace:^" + "@graphql-hive/logger": "workspace:^" "@graphql-hive/plugin-aws-sigv4": "workspace:^" "@graphql-hive/plugin-deduplicate-request": "workspace:^" "@graphql-hive/pubsub": "workspace:^" @@ -4122,7 +4124,7 @@ __metadata: languageName: unknown linkType: soft -"@graphql-hive/logger@workspace:packages/logger": +"@graphql-hive/logger@workspace:^, @graphql-hive/logger@workspace:packages/logger": version: 0.0.0-use.local resolution: "@graphql-hive/logger@workspace:packages/logger" dependencies: @@ -4383,6 +4385,7 @@ __metadata: dependencies: "@envelop/core": "npm:^5.2.3" "@envelop/instrumentation": "npm:^1.0.0" + "@graphql-hive/logger": "workspace:^" "@graphql-mesh/cross-helpers": "npm:^0.4.10" "@graphql-mesh/transport-common": "workspace:^" "@graphql-mesh/types": "npm:^0.104.0" @@ -4569,6 +4572,7 @@ __metadata: resolution: "@graphql-mesh/plugin-prometheus@workspace:packages/plugins/prometheus" dependencies: "@graphql-hive/gateway-runtime": "workspace:^" + "@graphql-hive/logger": "workspace:^" "@graphql-mesh/cross-helpers": "npm:^0.4.10" "@graphql-mesh/types": "npm:^0.104.0" "@graphql-mesh/utils": "npm:^0.104.2" @@ -4677,6 +4681,7 @@ __metadata: resolution: "@graphql-mesh/transport-common@workspace:packages/transports/common" dependencies: "@envelop/core": "npm:^5.2.3" + "@graphql-hive/logger": "workspace:^" "@graphql-hive/pubsub": "workspace:^" "@graphql-hive/signal": "workspace:^" "@graphql-mesh/cross-helpers": "npm:^0.4.10" From 3b98c54eba4bf8e6c6c9b44d66a696c094203326 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Tue, 8 Apr 2025 20:53:45 +0200 Subject: [PATCH 039/157] refactor more --- .../src/federation/supergraph.ts | 14 ++--- packages/fusion-runtime/src/utils.ts | 6 +- packages/fusion-runtime/tests/utils.ts | 7 +++ packages/gateway/src/config.ts | 7 +-- packages/runtime/src/fetchers/graphos.ts | 13 ++-- packages/runtime/tests/graphos.test.ts | 59 +++++++++++-------- 6 files changed, 59 insertions(+), 47 deletions(-) diff --git a/packages/fusion-runtime/src/federation/supergraph.ts b/packages/fusion-runtime/src/federation/supergraph.ts index 7589136dd..bbac6d3da 100644 --- a/packages/fusion-runtime/src/federation/supergraph.ts +++ b/packages/fusion-runtime/src/federation/supergraph.ts @@ -1,3 +1,4 @@ +import { LegacyLogger } from '@graphql-hive/logger'; import type { YamlConfig } from '@graphql-mesh/types'; import { getInContextSDK, @@ -158,7 +159,7 @@ export const handleFederationSupergraph: UnifiedGraphHandler = function ({ onDelegateHooks, additionalTypeDefs: additionalTypeDefsFromConfig = [], additionalResolvers: additionalResolversFromConfig = [], - logger, + log, }: UnifiedGraphHandlerOpts): UnifiedGraphHandlerResult { const additionalTypeDefs = [...asArray(additionalTypeDefsFromConfig)]; const additionalResolvers = [...asArray(additionalResolversFromConfig)]; @@ -230,7 +231,7 @@ export const handleFederationSupergraph: UnifiedGraphHandler = function ({ originalResolver, typeName, onDelegationStageExecuteHooks, - logger, + log, ); } } @@ -278,7 +279,7 @@ export const handleFederationSupergraph: UnifiedGraphHandler = function ({ executableUnifiedGraph, // @ts-expect-error Legacy Mesh RawSource is not compatible with new Mesh subschemas, - logger, + LegacyLogger.from(log), onDelegateHooks || [], ); const stitchingInfo = executableUnifiedGraph.extensions?.[ @@ -306,16 +307,15 @@ export const handleFederationSupergraph: UnifiedGraphHandler = function ({ delegationPlanBuilder = newDelegationPlanBuilder; } const onDelegationPlanDoneHooks: OnDelegationPlanDoneHook[] = []; - let currentLogger = logger; let requestId: string | undefined; if (context?.request) { requestId = requestIdByRequest.get(context.request); if (requestId) { - currentLogger = currentLogger?.child({ requestId }); + log = log.child({ requestId }); } } if (sourceSubschema.name) { - currentLogger = currentLogger?.child({ + log = log.child({ subgraph: sourceSubschema.name, }); } @@ -328,7 +328,7 @@ export const handleFederationSupergraph: UnifiedGraphHandler = function ({ variables, fragments, fieldNodes, - logger: currentLogger, + log, context, info, delegationPlanBuilder, diff --git a/packages/fusion-runtime/src/utils.ts b/packages/fusion-runtime/src/utils.ts index d9407a49e..17f967e0e 100644 --- a/packages/fusion-runtime/src/utils.ts +++ b/packages/fusion-runtime/src/utils.ts @@ -519,7 +519,7 @@ export interface OnDelegationPlanHookPayload { fieldNodes: SelectionNode[]; context: TContext; requestId?: string; - logger?: Logger; + log: Logger; info?: GraphQLResolveInfo; delegationPlanBuilder: DelegationPlanBuilder; setDelegationPlanBuilder(delegationPlanBuilder: DelegationPlanBuilder): void; @@ -556,7 +556,7 @@ export interface OnDelegationStageExecutePayload { typeName: string; requestId?: string; - logger?: Logger; + log: Logger; } export type OnDelegationStageExecuteDoneHook = ( @@ -634,7 +634,7 @@ export function wrapMergedTypeResolver>( typeName, type, requestId, - logger: log, + log, resolver, setResolver, }); diff --git a/packages/fusion-runtime/tests/utils.ts b/packages/fusion-runtime/tests/utils.ts index c723bf360..deaa00d39 100644 --- a/packages/fusion-runtime/tests/utils.ts +++ b/packages/fusion-runtime/tests/utils.ts @@ -1,3 +1,4 @@ +import { Logger } from '@graphql-hive/logger'; import { getUnifiedGraphGracefully, type SubgraphConfig, @@ -23,6 +24,9 @@ import { export function composeAndGetPublicSchema(subgraphs: SubgraphConfig[]) { const manager = new UnifiedGraphManager({ getUnifiedGraph: () => getUnifiedGraphGracefully(subgraphs), + transportContext: { + log: new Logger({ level: false }), + }, transports() { return { getSubgraphExecutor({ subgraphName }) { @@ -46,6 +50,9 @@ export function composeAndGetExecutor( ) { const manager = new UnifiedGraphManager({ getUnifiedGraph: () => getUnifiedGraphGracefully(subgraphs), + transportContext: { + log: new Logger({ level: false }), + }, transports() { return { getSubgraphExecutor({ subgraphName }) { diff --git a/packages/gateway/src/config.ts b/packages/gateway/src/config.ts index ea87b3468..a80a3c557 100644 --- a/packages/gateway/src/config.ts +++ b/packages/gateway/src/config.ts @@ -126,12 +126,7 @@ export async function getBuiltinPluginsFromConfig( const { useOpenTelemetry } = await import( '@graphql-mesh/plugin-opentelemetry' ); - plugins.push( - useOpenTelemetry({ - logger: ctx.logger, - ...config.openTelemetry, - }), - ); + plugins.push(useOpenTelemetry(config.openTelemetry)); } if (config.rateLimiting) { diff --git a/packages/runtime/src/fetchers/graphos.ts b/packages/runtime/src/fetchers/graphos.ts index 29ae7c273..6cd18f20f 100644 --- a/packages/runtime/src/fetchers/graphos.ts +++ b/packages/runtime/src/fetchers/graphos.ts @@ -56,8 +56,8 @@ export function createGraphOSFetcher({ graphosOpts.upLink || process.env['APOLLO_SCHEMA_CONFIG_DELIVERY_ENDPOINT']; const uplinks = uplinksParam?.split(',').map((uplink) => uplink.trim()) || DEFAULT_UPLINKS; - const graphosLogger = configContext.logger.child({ source: 'GraphOS' }); - graphosLogger.info('Using GraphOS with uplinks ', ...uplinks); + const log = configContext.log.child({ source: 'GraphOS' }); + log.info({ uplinks }, 'Using GraphOS with uplinks'); let supergraphLoadedPlace = defaultLoadedPlacePrefix; if (graphosOpts.graphRef) { supergraphLoadedPlace += `
${graphosOpts.graphRef}`; @@ -80,8 +80,9 @@ export function createGraphOSFetcher({ const currentTime = Date.now(); if (nextFetchTime >= currentTime) { const delay = nextFetchTime - currentTime; - graphosLogger.info( - `Fetching supergraph with delay: ${millisecondsToStr(delay)}`, + log.info( + 'Fetching supergraph with delay %s', + millisecondsToStr(delay), ); nextFetchTime = 0; return delayInMs(delay).then(fetchSupergraph); @@ -101,8 +102,8 @@ export function createGraphOSFetcher({ if (maxRetries > 1) { attemptMetadata['attempt'] = `${maxRetries - retries}/${maxRetries}`; } - const attemptLogger = graphosLogger.child(attemptMetadata); - attemptLogger.debug(`Fetching supergraph`); + const attemptLogger = log.child(attemptMetadata); + attemptLogger.debug('Fetching supergraph'); return handleMaybePromise( () => fetchSupergraphSdlFromManagedFederation({ diff --git a/packages/runtime/tests/graphos.test.ts b/packages/runtime/tests/graphos.test.ts index b1135db8e..8dc653ace 100644 --- a/packages/runtime/tests/graphos.test.ts +++ b/packages/runtime/tests/graphos.test.ts @@ -1,9 +1,9 @@ import { setTimeout } from 'timers/promises'; import { - JSONLogger, type GatewayConfigContext, type GatewayGraphOSManagedFederationOptions, } from '@graphql-hive/gateway-runtime'; +import { Logger } from '@graphql-hive/logger'; import { Response } from '@whatwg-node/fetch'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createGraphOSFetcher } from '../src/fetchers/graphos'; @@ -20,7 +20,9 @@ describe('GraphOS', () => { it('should fetch the supergraph SDL', async () => { const { unifiedGraphFetcher } = createTestFetcher({ fetch: mockSDL }); - const result = Promise.resolve().then(() => unifiedGraphFetcher({})); + const result = Promise.resolve().then(() => + unifiedGraphFetcher({ log: new Logger({ level: false }) }), + ); await advanceTimersByTimeAsync(1_000); expect(await result).toBe(supergraphSdl); }); @@ -37,7 +39,9 @@ describe('GraphOS', () => { }, }); - const result = Promise.resolve().then(() => unifiedGraphFetcher({})); + const result = Promise.resolve().then(() => + unifiedGraphFetcher({ log: new Logger({ level: false }) }), + ); for (let i = 0; i < 3; i++) { await advanceTimersByTimeAsync(1_000); } @@ -52,7 +56,7 @@ describe('GraphOS', () => { ); const result = Promise.resolve() - .then(() => unifiedGraphFetcher({})) + .then(() => unifiedGraphFetcher({ log: new Logger({ level: false }) })) .catch((err) => err); for (let i = 0; i < 3; i++) { await advanceTimersByTimeAsync(1_000); @@ -68,7 +72,7 @@ describe('GraphOS', () => { ); const result = Promise.resolve() - .then(() => unifiedGraphFetcher({})) + .then(() => unifiedGraphFetcher({ log: new Logger({ level: false }) })) .catch(() => {}); await advanceTimersByTimeAsync(25); expect(mockFetchError).toHaveBeenCalledTimes(1); @@ -84,12 +88,16 @@ describe('GraphOS', () => { it('should respect min-delay between polls', async () => { const { unifiedGraphFetcher } = createTestFetcher({ fetch: mockSDL }); - Promise.resolve().then(() => unifiedGraphFetcher({})); + Promise.resolve().then(() => + unifiedGraphFetcher({ log: new Logger({ level: false }) }), + ); await advanceTimersByTimeAsync(25); expect(mockSDL).toHaveBeenCalledTimes(1); await advanceTimersByTimeAsync(20); expect(mockSDL).toHaveBeenCalledTimes(1); - Promise.resolve().then(() => unifiedGraphFetcher({})); + Promise.resolve().then(() => + unifiedGraphFetcher({ log: new Logger({ level: false }) }), + ); await advanceTimersByTimeAsync(50); expect(mockSDL).toHaveBeenCalledTimes(1); await advanceTimersByTimeAsync(50); @@ -107,19 +115,27 @@ describe('GraphOS', () => { return mockSDL(); }, }); - const result1 = Promise.resolve().then(() => unifiedGraphFetcher({})); + const result1 = Promise.resolve().then(() => + unifiedGraphFetcher({ log: new Logger({ level: false }) }), + ); await advanceTimersByTimeAsync(1_000); - const result2 = Promise.resolve().then(() => unifiedGraphFetcher({})); + const result2 = Promise.resolve().then(() => + unifiedGraphFetcher({ log: new Logger({ level: false }) }), + ); await advanceTimersByTimeAsync(1_000); expect(await result1).toBe(await result2); }, 30_000); it('should not wait if min delay is superior to polling interval', async () => { const { unifiedGraphFetcher } = createTestFetcher({ fetch: mockSDL }); - const result = Promise.resolve().then(() => unifiedGraphFetcher({})); + const result = Promise.resolve().then(() => + unifiedGraphFetcher({ log: new Logger({ level: false }) }), + ); await advanceTimersByTimeAsync(1_000); await result; - const result2 = Promise.resolve().then(() => unifiedGraphFetcher({})); + const result2 = Promise.resolve().then(() => + unifiedGraphFetcher({ log: new Logger({ level: false }) }), + ); await advanceTimersByTimeAsync(1_000); expect(await result).toBe(await result2); }); @@ -146,9 +162,13 @@ describe('GraphOS', () => { }, }); - const result = Promise.resolve().then(() => unifiedGraphFetcher({})); + const result = Promise.resolve().then(() => + unifiedGraphFetcher({ log: new Logger({ level: false }) }), + ); await advanceTimersByTimeAsync(1_000); - const result2 = Promise.resolve().then(() => unifiedGraphFetcher({})); + const result2 = Promise.resolve().then(() => + unifiedGraphFetcher({ log: new Logger({ level: false }) }), + ); await advanceTimersByTimeAsync(1_000); expect(await result).toBe(await result2); }); @@ -163,18 +183,7 @@ function createTestFetcher( ) { return createGraphOSFetcher({ configContext: { - logger: process.env['DEBUG'] - ? new JSONLogger() - : { - child() { - return this; - }, - info: () => {}, - debug: () => {}, - error: () => {}, - warn: () => {}, - log: () => {}, - }, + log: new Logger({ level: process.env['DEBUG'] ? 'debug' : false }), cwd: process.cwd(), ...configContext, }, From a065c060758366ce736ebe6dac1cecbe972822c3 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Wed, 9 Apr 2025 15:41:52 +0200 Subject: [PATCH 040/157] remove logger from cache hooks --- packages/runtime/src/types.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/runtime/src/types.ts b/packages/runtime/src/types.ts index 71c4c6c53..366b4eb6b 100644 --- a/packages/runtime/src/types.ts +++ b/packages/runtime/src/types.ts @@ -117,7 +117,6 @@ export type OnCacheGetHook = ( ) => MaybePromise; export interface OnCacheGetHookEventPayload { - log: Logger; cache: KeyValueCache; key: string; ttl?: number; @@ -132,13 +131,11 @@ export interface OnCacheGetHookResult { export type OnCacheErrorHook = (payload: OnCacheErrorHookPayload) => void; export interface OnCacheErrorHookPayload { - log: Logger; error: Error; } export type OnCacheHitHook = (payload: OnCacheHitHookEventPayload) => void; export interface OnCacheHitHookEventPayload { - log: Logger; value: any; } export type OnCacheMissHook = () => void; @@ -153,7 +150,6 @@ export interface OnCacheSetHookResult { } export interface OnCacheSetHookEventPayload { - log: Logger; cache: KeyValueCache; key: string; value: any; @@ -170,7 +166,6 @@ export interface OnCacheDeleteHookResult { } export interface OnCacheDeleteHookEventPayload { - log: Logger; cache: KeyValueCache; key: string; } From 4d7e8c861581b04ae756fc4ba77793ae685c645d Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Wed, 9 Apr 2025 15:54:25 +0200 Subject: [PATCH 041/157] legacylogger for hive console plugin --- packages/runtime/src/plugins/useHiveConsole.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/runtime/src/plugins/useHiveConsole.ts b/packages/runtime/src/plugins/useHiveConsole.ts index 9931077dc..6e5a0336b 100644 --- a/packages/runtime/src/plugins/useHiveConsole.ts +++ b/packages/runtime/src/plugins/useHiveConsole.ts @@ -1,5 +1,5 @@ import type { HivePluginOptions } from '@graphql-hive/core'; -import type { Logger } from '@graphql-hive/logger'; +import { LegacyLogger, type Logger } from '@graphql-hive/logger'; import { useHive } from '@graphql-hive/yoga'; import { process } from '@graphql-mesh/cross-helpers'; import { GatewayPlugin } from '../types'; @@ -39,7 +39,7 @@ export default function useHiveConsole< > { const agent: HiveConsolePluginOptions['agent'] = { name: 'hive-gateway', - logger: 'TODO', + logger: LegacyLogger.from(options.log), ...options.agent, }; From e36eadf6e9e19e5a8b4ce69cae7474baab0b0e62 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Wed, 9 Apr 2025 15:57:39 +0200 Subject: [PATCH 042/157] retry logger --- .../runtime/src/plugins/useRetryOnSchemaReload.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/runtime/src/plugins/useRetryOnSchemaReload.ts b/packages/runtime/src/plugins/useRetryOnSchemaReload.ts index da0256020..adddb51ab 100644 --- a/packages/runtime/src/plugins/useRetryOnSchemaReload.ts +++ b/packages/runtime/src/plugins/useRetryOnSchemaReload.ts @@ -24,13 +24,14 @@ export function useRetryOnSchemaReload< } } } + const logForRequest = new WeakMap(); function handleExecutionResult({ context, result, setResult, request, }: { - context: { log: Logger }; + context: {}; result?: ExecutionResult; setResult: (result: MaybeAsyncIterable) => void; request: Request; @@ -40,7 +41,7 @@ export function useRetryOnSchemaReload< execHandler && result?.errors?.some((e) => e.extensions?.['code'] === 'SCHEMA_RELOAD') ) { - let log = context.log; + let log = logForRequest.get(request)!; // must exist at this point const requestId = requestIdByRequest.get(request); if (requestId) { log = log.child({ requestId }); @@ -65,10 +66,14 @@ export function useRetryOnSchemaReload< }), ); }, - onExecute({ args }) { + onExecute({ args, context }) { + // we set the logger here because it most likely contains important attributes (like the request-id) + logForRequest.set(context.request, context.log); handleOnExecute(args); }, - onSubscribe({ args }) { + onSubscribe({ args, context }) { + // we set the logger here because it most likely contains important attributes (like the request-id) + logForRequest.set(context.request, context.log); handleOnExecute(args); }, onExecutionResult({ request, context, result, setResult }) { From 96cc3aa685136b78196b829eedb07d46f190931b Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Wed, 9 Apr 2025 16:26:08 +0200 Subject: [PATCH 043/157] pass in logger to usewebhooks --- packages/runtime/src/plugins/useWebhooks.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/runtime/src/plugins/useWebhooks.ts b/packages/runtime/src/plugins/useWebhooks.ts index 63175f246..b8d1d7201 100644 --- a/packages/runtime/src/plugins/useWebhooks.ts +++ b/packages/runtime/src/plugins/useWebhooks.ts @@ -1,14 +1,15 @@ +import type { Logger } from '@graphql-hive/logger'; import { HivePubSub } from '@graphql-hive/pubsub'; -import type { Logger } from '@graphql-mesh/types'; import { handleMaybePromise } from '@whatwg-node/promise-helpers'; -import type { Plugin } from 'graphql-yoga'; import { GatewayPlugin } from '../types'; export interface GatewayWebhooksPluginOptions { + log: Logger; pubsub?: HivePubSub; } export function useWebhooks({ + log, pubsub, }: GatewayWebhooksPluginOptions): GatewayPlugin { if (!pubsub) { @@ -32,13 +33,14 @@ export function useWebhooks({ const expectedEventName = `webhook:${requestMethod}:${pathname}`; for (const eventName of eventNames) { if (eventName === expectedEventName) { - logger?.debug(() => `Received webhook request for ${pathname}`); + log.debug('Received webhook request for %s', pathname); return handleMaybePromise( () => request.text(), function handleWebhookPayload(webhookPayload) { - logger?.debug( - () => - `Emitted webhook request for ${pathname}: ${webhookPayload}`, + log.debug( + 'Emitted webhook request for %s: %s', + pathname, + webhookPayload, ); webhookPayload = request.headers.get('content-type') === 'application/json' From fda731653199ca92d5645639d86b0054caa2d8db Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Wed, 9 Apr 2025 17:00:46 +0200 Subject: [PATCH 044/157] use hmac --- e2e/hmac-auth-https/services/users/index.ts | 2 ++ .../hmac-upstream-signature/src/index.ts | 17 ++++++++--------- .../tests/hmac-upstream-signature.spec.ts | 3 +++ 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/e2e/hmac-auth-https/services/users/index.ts b/e2e/hmac-auth-https/services/users/index.ts index c77a92e52..a3d375c0c 100644 --- a/e2e/hmac-auth-https/services/users/index.ts +++ b/e2e/hmac-auth-https/services/users/index.ts @@ -2,6 +2,7 @@ import { readFileSync } from 'fs'; import { createServer } from 'https'; import { join } from 'path'; import { buildSubgraphSchema } from '@apollo/subgraph'; +import { Logger } from '@graphql-hive/logger'; import { useHmacSignatureValidation } from '@graphql-mesh/hmac-upstream-signature'; import { JWTExtendContextFields, @@ -20,6 +21,7 @@ const yoga = createYoga({ logging: true, plugins: [ useHmacSignatureValidation({ + log: new Logger({ level: 'debug' }), secret: 'HMAC_SIGNING_SECRET', }), useForwardedJWT({}), diff --git a/packages/plugins/hmac-upstream-signature/src/index.ts b/packages/plugins/hmac-upstream-signature/src/index.ts index 7590c84f7..fcd9b0be6 100644 --- a/packages/plugins/hmac-upstream-signature/src/index.ts +++ b/packages/plugins/hmac-upstream-signature/src/index.ts @@ -1,4 +1,5 @@ import type { GatewayPlugin } from '@graphql-hive/gateway-runtime'; +import type { Logger } from '@graphql-hive/logger'; import type { OnSubgraphExecutePayload } from '@graphql-mesh/fusion-runtime'; import { serializeExecutionRequest } from '@graphql-tools/executor-common'; import type { ExecutionRequest } from '@graphql-tools/utils'; @@ -143,6 +144,7 @@ export function useHmacUpstreamSignature( } export type HMACUpstreamSignatureValidationOptions = { + log: Logger; secret: string; extensionName?: string; serializeParams?: (params: GraphQLParams) => string; @@ -163,15 +165,11 @@ export function useHmacSignatureValidation( const paramsSerializer = options.serializeParams || defaultParamsSerializer; return { - onParams({ params, fetchAPI, context }) { + onParams({ params, fetchAPI }) { textEncoder ||= new fetchAPI.TextEncoder(); const extension = params.extensions?.[extensionName]; if (!extension) { - logger.warn( - `Missing HMAC signature: extension ${extensionName} not found in request.`, - ); - throw new Error( `Missing HMAC signature: extension ${extensionName} not found in request.`, ); @@ -191,8 +189,9 @@ export function useHmacSignatureValidation( c.charCodeAt(0), ); const serializedParams = paramsSerializer(params); - logger.debug( - `HMAC signature will be calculate based on serialized params: ${serializedParams}`, + options.log.debug( + 'HMAC signature will be calculate based on serialized params %s', + serializedParams, ); return handleMaybePromise( @@ -205,8 +204,8 @@ export function useHmacSignatureValidation( ), (result) => { if (!result) { - logger.error( - `HMAC signature does not match the body content. short circuit request.`, + options.log.error( + 'HMAC signature does not match the body content. short circuit request.', ); throw new Error( diff --git a/packages/plugins/hmac-upstream-signature/tests/hmac-upstream-signature.spec.ts b/packages/plugins/hmac-upstream-signature/tests/hmac-upstream-signature.spec.ts index 2100ecfcc..a14d9c586 100644 --- a/packages/plugins/hmac-upstream-signature/tests/hmac-upstream-signature.spec.ts +++ b/packages/plugins/hmac-upstream-signature/tests/hmac-upstream-signature.spec.ts @@ -4,6 +4,7 @@ import { GatewayPlugin, useCustomFetch, } from '@graphql-hive/gateway-runtime'; +import { Logger } from '@graphql-hive/logger'; import { getUnifiedGraphGracefully } from '@graphql-mesh/fusion-composition'; import { MeshFetch } from '@graphql-mesh/types'; import { GraphQLSchema, stripIgnoredCharacters } from 'graphql'; @@ -61,6 +62,7 @@ for (const [name, createConfig] of Object.entries(cases)) { ...createConfig(upstreamSchema), plugins: () => [ useHmacSignatureValidation({ + log: new Logger({ level: false }), secret: 'topSecret', }), useCustomFetch(upstream.fetch as MeshFetch), @@ -119,6 +121,7 @@ for (const [name, createConfig] of Object.entries(cases)) { schema: upstreamSchema, plugins: [ useHmacSignatureValidation({ + log: new Logger({ level: false }), secret: sharedSecret, }), ], From 50bf5f3e7efe2277f98512be6082995405b10ca6 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Wed, 9 Apr 2025 17:54:24 +0200 Subject: [PATCH 045/157] some todos --- packages/logger/tests/Logger.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/logger/tests/Logger.test.ts b/packages/logger/tests/Logger.test.ts index 6363e2394..5c91f7fe4 100644 --- a/packages/logger/tests/Logger.test.ts +++ b/packages/logger/tests/Logger.test.ts @@ -337,3 +337,9 @@ it('should write logs with unexpected attributes', () => { ] `); }); + +it.todo('should serialise aggregate errors'); + +it.todo('should serialise error causes'); + +it.todo('should serialise using the toJSON method'); From 1cd36b9c13d336d1c0c7ed606e14f7a113fdd97e Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Wed, 9 Apr 2025 17:55:13 +0200 Subject: [PATCH 046/157] drop for now --- e2e/graphos-polling/services/gateway-fastify.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/e2e/graphos-polling/services/gateway-fastify.ts b/e2e/graphos-polling/services/gateway-fastify.ts index 2e038bab7..a542af8fe 100644 --- a/e2e/graphos-polling/services/gateway-fastify.ts +++ b/e2e/graphos-polling/services/gateway-fastify.ts @@ -1,5 +1,4 @@ import { createGatewayRuntime } from '@graphql-hive/gateway-runtime'; -import { createLoggerFromPino } from '@graphql-hive/logger-pino'; import { createOtlpHttpExporter, useOpenTelemetry, @@ -43,8 +42,6 @@ export interface FastifyContext { } const gw = createGatewayRuntime({ - // Integrate Fastify's logger / Pino with the gateway logger - logging: createLoggerFromPino(app.log), // Align with Fastify requestId: { // Use the same header name as Fastify From ef1002c9d928408858bd4805cb6c6ebdb5ab0ff1 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Wed, 9 Apr 2025 17:59:19 +0200 Subject: [PATCH 047/157] no color if no process --- packages/logger/src/writers.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/logger/src/writers.ts b/packages/logger/src/writers.ts index 12b84ac21..77d228dbe 100644 --- a/packages/logger/src/writers.ts +++ b/packages/logger/src/writers.ts @@ -45,7 +45,12 @@ const asciMap = { }; export class ConsoleLogWriter implements LogWriter { - #nocolor = truthyEnv('NO_COLOR'); + #nocolor = + // no color if we're running in browser-like (edge) environments + // TODO: is this the most accurate way to detect it? + typeof process === 'undefined' || + // no color if https://no-color.org/ + truthyEnv('NO_COLOR'); color(style: keyof typeof asciMap, text: string | null | undefined) { if (!text) { return text; From b445750bd197b52a0f206765ee721c2ea7441dab Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Wed, 9 Apr 2025 18:10:39 +0200 Subject: [PATCH 048/157] logger -> log --- packages/gateway/src/commands/proxy.ts | 2 +- packages/gateway/src/commands/subgraph.ts | 2 +- packages/gateway/src/commands/supergraph.ts | 2 +- packages/gateway/src/config.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/gateway/src/commands/proxy.ts b/packages/gateway/src/commands/proxy.ts index 268b5f565..ba099ad59 100644 --- a/packages/gateway/src/commands/proxy.ts +++ b/packages/gateway/src/commands/proxy.ts @@ -128,7 +128,7 @@ export const addCommand: AddCommand = (ctx, cli) => ...opts, }, { - logger: ctx.log, + log: ctx.log, cache, pubsub, cwd, diff --git a/packages/gateway/src/commands/subgraph.ts b/packages/gateway/src/commands/subgraph.ts index 8ccf1bfbe..874148a61 100644 --- a/packages/gateway/src/commands/subgraph.ts +++ b/packages/gateway/src/commands/subgraph.ts @@ -88,7 +88,7 @@ export const addCommand: AddCommand = (ctx, cli) => ...opts, }, { - logger: ctx.log, + log: ctx.log, cache, pubsub, cwd, diff --git a/packages/gateway/src/commands/supergraph.ts b/packages/gateway/src/commands/supergraph.ts index 7ef2e827f..3c63c8df8 100644 --- a/packages/gateway/src/commands/supergraph.ts +++ b/packages/gateway/src/commands/supergraph.ts @@ -186,7 +186,7 @@ export const addCommand: AddCommand = (ctx, cli) => ...opts, }, { - logger: ctx.log, + log: ctx.log, cache, pubsub, cwd, diff --git a/packages/gateway/src/config.ts b/packages/gateway/src/config.ts index a80a3c557..caaf6258e 100644 --- a/packages/gateway/src/config.ts +++ b/packages/gateway/src/config.ts @@ -106,7 +106,7 @@ export async function getBuiltinPluginsFromConfig( config: GatewayCLIBuiltinPluginConfig, ctx: { cache: KeyValueCache; - logger: Logger; + log: Logger; pubsub: HivePubSub; cwd: string; }, From bfc291758657cb43723234b3b459a1c502b11216 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Wed, 9 Apr 2025 18:10:48 +0200 Subject: [PATCH 049/157] nest logger writer --- packages/nestjs/src/index.ts | 143 +++++++++-------------------------- 1 file changed, 34 insertions(+), 109 deletions(-) diff --git a/packages/nestjs/src/index.ts b/packages/nestjs/src/index.ts index e4f14f710..7e772587f 100644 --- a/packages/nestjs/src/index.ts +++ b/packages/nestjs/src/index.ts @@ -1,5 +1,6 @@ import { createGatewayRuntime, + createLoggerFromLogging, GatewayCLIBuiltinPluginConfig, GatewayConfigProxy, GatewayConfigSubgraph, @@ -11,10 +12,7 @@ import { PubSub, type GatewayRuntime, } from '@graphql-hive/gateway'; -import { - Logger as GatewayLogger, - type LazyLoggerMessage, -} from '@graphql-mesh/types'; +import { Logger as HiveLogger } from '@graphql-hive/logger'; import { asArray, type IResolvers, @@ -63,6 +61,7 @@ export class HiveGatewayDriver< private async ensureGatewayRuntime({ typeDefs, resolvers, + logging, ...options }: HiveGatewayDriverConfig) { if (this._gatewayRuntime) { @@ -78,14 +77,37 @@ export class HiveGatewayDriver< if (resolvers) { additionalResolvers.push(...asArray(resolvers)); } - const logger = new NestJSLoggerAdapter( - 'Hive Gateway', - {}, - new NestLogger('Hive Gateway'), - options.debug ?? truthy(process.env['DEBUG']), - ); + + let log: HiveLogger; + if (logging != null) { + log = createLoggerFromLogging(logging); + } else { + const nestLog = new NestLogger('Hive Gateway'); + log = new HiveLogger({ + writers: [ + { + write(level, attrs, msg) { + switch (level) { + case 'trace': + nestLog.verbose(msg, attrs); + break; + case 'info': + nestLog.log(msg, attrs); + break; + default: + nestLog[level](msg, attrs); + } + }, + flush() { + // noop + }, + }, + ], + }); + } + const configCtx = { - logger, + log, cwd: process.cwd(), pubsub: options.pubsub || new PubSub(), }; @@ -96,7 +118,7 @@ export class HiveGatewayDriver< }); this._gatewayRuntime = createGatewayRuntime({ ...options, - logging: configCtx.logger, + logging: configCtx.log, cache, graphqlEndpoint: options.path, additionalTypeDefs, @@ -287,100 +309,3 @@ export class HiveGatewayDriver< }); } } - -class NestJSLoggerAdapter implements GatewayLogger { - constructor( - public name: string, - private meta: Record, - private logger: NestLogger, - private isDebug: boolean, - ) {} - private prepareMessage(args: LazyLoggerMessage[]) { - const obj = { - ...(this.meta || {}), - }; - const strs: string[] = []; - const flattenedArgs = args - .flatMap((arg) => (typeof arg === 'function' ? arg() : arg)) - .flat(Number.POSITIVE_INFINITY); - for (const arg of flattenedArgs) { - if (typeof arg === 'string' || typeof arg === 'number') { - strs.push(arg.toString()); - } else { - Object.assign(obj, arg); - } - } - return { obj, str: strs.join(', ') }; - } - log(...args: any[]) { - const { obj, str } = this.prepareMessage(args); - if (Object.keys(obj).length) { - this.logger.log(obj, str); - } else { - this.logger.log(str); - } - } - info(...args: any[]) { - const { obj, str } = this.prepareMessage(args); - if (Object.keys(obj).length) { - this.logger.log(obj, str); - } else { - this.logger.log(str); - } - } - error(...args: any[]) { - const { obj, str } = this.prepareMessage(args); - if (Object.keys(obj).length) { - this.logger.error(obj, str); - } else { - this.logger.error(str); - } - } - warn(...args: any[]) { - const { obj, str } = this.prepareMessage(args); - if (Object.keys(obj).length) { - this.logger.warn(obj, str); - } else { - this.logger.warn(str); - } - } - debug(...args: any[]) { - if (!this.isDebug) { - return; - } - const { obj, str } = this.prepareMessage(args); - if (Object.keys(obj).length) { - this.logger.debug(obj, str); - } else { - this.logger.debug(str); - } - } - child( - newNameOrMeta: string | Record, - ): NestJSLoggerAdapter { - const newName = - typeof newNameOrMeta === 'string' - ? this.name - ? `${this.name}, ${newNameOrMeta}` - : newNameOrMeta - : this.name; - const newMeta = - typeof newNameOrMeta === 'string' - ? this.meta - : { ...this.meta, ...newNameOrMeta }; - return new NestJSLoggerAdapter( - newName, - newMeta, - new NestLogger(newName), - this.isDebug, - ); - } -} - -function truthy(val: unknown) { - return ( - val === true || - val === 1 || - ['1', 't', 'true', 'y', 'yes'].includes(String(val)) - ); -} From a743dd0dd877d8b0a5ea669e12cf7ec216a0a4d9 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Wed, 9 Apr 2025 18:18:29 +0200 Subject: [PATCH 050/157] fix unit tests --- packages/fusion-runtime/tests/runtime.test.ts | 3 ++- packages/plugins/aws-sigv4/tests/aws-sigv4-incoming.test.ts | 2 +- packages/transports/ws/tests/ws.spec.ts | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/fusion-runtime/tests/runtime.test.ts b/packages/fusion-runtime/tests/runtime.test.ts index ab2171a66..0224582b2 100644 --- a/packages/fusion-runtime/tests/runtime.test.ts +++ b/packages/fusion-runtime/tests/runtime.test.ts @@ -1,4 +1,5 @@ import { buildSubgraphSchema } from '@apollo/subgraph'; +import { Logger } from '@graphql-hive/logger'; import { OnDelegationPlanDoneHook, OnDelegationPlanHook, @@ -264,7 +265,7 @@ describe('onDelegationPlanHook', () => { context, delegationPlanBuilder: expect.any(Function), setDelegationPlanBuilder: expect.any(Function), - logger: undefined, + log: expect.any(Logger), info: expect.any(Object), }); expect( diff --git a/packages/plugins/aws-sigv4/tests/aws-sigv4-incoming.test.ts b/packages/plugins/aws-sigv4/tests/aws-sigv4-incoming.test.ts index 26d452c60..f2c956409 100644 --- a/packages/plugins/aws-sigv4/tests/aws-sigv4-incoming.test.ts +++ b/packages/plugins/aws-sigv4/tests/aws-sigv4-incoming.test.ts @@ -10,7 +10,7 @@ import { createYoga } from 'graphql-yoga'; import { describe, expect, it } from 'vitest'; import { useAWSSigv4 } from '../src'; -describe('AWS Sigv4 Incoming requests', () => { +describe.todo('AWS Sigv4 Incoming requests', () => { const subgraphSchema = buildSubgraphSchema({ typeDefs: parse(/* GraphQL */ ` type Query { diff --git a/packages/transports/ws/tests/ws.spec.ts b/packages/transports/ws/tests/ws.spec.ts index 0e71544ec..e9d7f0c68 100644 --- a/packages/transports/ws/tests/ws.spec.ts +++ b/packages/transports/ws/tests/ws.spec.ts @@ -1,4 +1,4 @@ -import { JSONLogger } from '@graphql-hive/logger-json'; +import { Logger } from '@graphql-hive/logger'; import type { TransportEntry, TransportGetSubgraphExecutorOptions, @@ -79,7 +79,7 @@ async function createTServer( ...transportEntry, options, }, - logger: new JSONLogger(), + log: new Logger({ level: false }), } as unknown as TransportGetSubgraphExecutorOptions, onClient, ); From 72660e9a26fb492f4c60987685e53c568ab04c24 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Wed, 9 Apr 2025 18:28:02 +0200 Subject: [PATCH 051/157] config syntax --- e2e/config-syntax-error/config-syntax-error.e2e.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e/config-syntax-error/config-syntax-error.e2e.ts b/e2e/config-syntax-error/config-syntax-error.e2e.ts index d168008eb..5d5f87795 100644 --- a/e2e/config-syntax-error/config-syntax-error.e2e.ts +++ b/e2e/config-syntax-error/config-syntax-error.e2e.ts @@ -31,8 +31,8 @@ it.skipIf( }), ).rejects.toThrowError( gatewayRunner === 'bun' || gatewayRunner === 'bun-docker' - ? /error: Expected "{" but found "hello"(.|\n)*\/custom-resolvers.ts:8:11/ - : /SyntaxError \[Error\]: Error transforming .*(\/|\\)custom-resolvers.ts: Unexpected token, expected "{" \(8:11\)/, + ? /error: Expected \\"{\\" but found \\"hello\\"(.|\n)*\/custom-resolvers.ts:8:11/ + : /Error transforming .*(\/|\\)custom-resolvers.ts: Unexpected token, expected \\"{\\" \(8:11\)/, ); }, ); From 477a860a31856d4015ad48c6d8ae0dc4afe15944 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Wed, 9 Apr 2025 18:37:21 +0200 Subject: [PATCH 052/157] format --- packages/logger/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/logger/package.json b/packages/logger/package.json index ebcff3dab..921bb3fb3 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -17,6 +17,7 @@ "node": ">=18.0.0" }, "main": "./dist/index.js", + "types": "./dist/index.d.ts", "exports": { ".": { "require": { @@ -30,7 +31,6 @@ }, "./package.json": "./package.json" }, - "types": "./dist/index.d.ts", "files": [ "dist" ], From ae4c7ddc671d363b6b3791a9664e85e266f369de Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 9 Apr 2025 16:39:39 +0000 Subject: [PATCH 053/157] chore(dependencies): updated changesets for modified dependencies --- .changeset/@graphql-hive_gateway-1030-dependencies.md | 7 +++++++ .../@graphql-hive_gateway-runtime-1030-dependencies.md | 7 +++++++ .../@graphql-mesh_fusion-runtime-1030-dependencies.md | 7 +++++++ .../@graphql-mesh_plugin-prometheus-1030-dependencies.md | 7 +++++++ .../@graphql-mesh_transport-common-1030-dependencies.md | 7 +++++++ 5 files changed, 35 insertions(+) create mode 100644 .changeset/@graphql-hive_gateway-1030-dependencies.md create mode 100644 .changeset/@graphql-hive_gateway-runtime-1030-dependencies.md create mode 100644 .changeset/@graphql-mesh_fusion-runtime-1030-dependencies.md create mode 100644 .changeset/@graphql-mesh_plugin-prometheus-1030-dependencies.md create mode 100644 .changeset/@graphql-mesh_transport-common-1030-dependencies.md diff --git a/.changeset/@graphql-hive_gateway-1030-dependencies.md b/.changeset/@graphql-hive_gateway-1030-dependencies.md new file mode 100644 index 000000000..fc6e6bbec --- /dev/null +++ b/.changeset/@graphql-hive_gateway-1030-dependencies.md @@ -0,0 +1,7 @@ +--- +'@graphql-hive/gateway': patch +--- + +dependencies updates: + +- Added dependency [`@graphql-hive/logger@workspace:^` ↗︎](https://www.npmjs.com/package/@graphql-hive/logger/v/workspace:^) (to `dependencies`) diff --git a/.changeset/@graphql-hive_gateway-runtime-1030-dependencies.md b/.changeset/@graphql-hive_gateway-runtime-1030-dependencies.md new file mode 100644 index 000000000..c2dbac50f --- /dev/null +++ b/.changeset/@graphql-hive_gateway-runtime-1030-dependencies.md @@ -0,0 +1,7 @@ +--- +'@graphql-hive/gateway-runtime': patch +--- + +dependencies updates: + +- Added dependency [`@graphql-hive/logger@workspace:^` ↗︎](https://www.npmjs.com/package/@graphql-hive/logger/v/workspace:^) (to `dependencies`) diff --git a/.changeset/@graphql-mesh_fusion-runtime-1030-dependencies.md b/.changeset/@graphql-mesh_fusion-runtime-1030-dependencies.md new file mode 100644 index 000000000..d381bdd14 --- /dev/null +++ b/.changeset/@graphql-mesh_fusion-runtime-1030-dependencies.md @@ -0,0 +1,7 @@ +--- +'@graphql-mesh/fusion-runtime': patch +--- + +dependencies updates: + +- Added dependency [`@graphql-hive/logger@workspace:^` ↗︎](https://www.npmjs.com/package/@graphql-hive/logger/v/workspace:^) (to `dependencies`) diff --git a/.changeset/@graphql-mesh_plugin-prometheus-1030-dependencies.md b/.changeset/@graphql-mesh_plugin-prometheus-1030-dependencies.md new file mode 100644 index 000000000..02e9f8bf5 --- /dev/null +++ b/.changeset/@graphql-mesh_plugin-prometheus-1030-dependencies.md @@ -0,0 +1,7 @@ +--- +'@graphql-mesh/plugin-prometheus': patch +--- + +dependencies updates: + +- Added dependency [`@graphql-hive/logger@workspace:^` ↗︎](https://www.npmjs.com/package/@graphql-hive/logger/v/workspace:^) (to `dependencies`) diff --git a/.changeset/@graphql-mesh_transport-common-1030-dependencies.md b/.changeset/@graphql-mesh_transport-common-1030-dependencies.md new file mode 100644 index 000000000..2096b7a2b --- /dev/null +++ b/.changeset/@graphql-mesh_transport-common-1030-dependencies.md @@ -0,0 +1,7 @@ +--- +'@graphql-mesh/transport-common': patch +--- + +dependencies updates: + +- Added dependency [`@graphql-hive/logger@workspace:^` ↗︎](https://www.npmjs.com/package/@graphql-hive/logger/v/workspace:^) (to `dependencies`) From dd0735b09fe1ee26d7b12e7dd055c0f2a1c987e0 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Wed, 9 Apr 2025 18:39:48 +0200 Subject: [PATCH 054/157] logger 0.0.0 --- packages/logger/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/logger/package.json b/packages/logger/package.json index 921bb3fb3..580b91f65 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -1,6 +1,6 @@ { "name": "@graphql-hive/logger", - "version": "1.0.0", + "version": "0.0.0", "type": "module", "repository": { "type": "git", From a07b33532cdc4452191dad2c442e0777febc9e26 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 10 Apr 2025 11:45:34 +0200 Subject: [PATCH 055/157] logger level can be changed --- packages/logger/src/Logger.ts | 16 +++++-- packages/logger/src/utils.ts | 3 +- packages/logger/tests/Logger.test.ts | 70 ++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 6 deletions(-) diff --git a/packages/logger/src/Logger.ts b/packages/logger/src/Logger.ts index 0e919d08a..30b24639c 100644 --- a/packages/logger/src/Logger.ts +++ b/packages/logger/src/Logger.ts @@ -23,9 +23,11 @@ export interface LoggerOptions { * * Providing `false` will disable all logging. * + * Provided function will always be invoked to get the current log level. + * * @default env.LOG_LEVEL || env.DEBUG ? 'debug' : 'info' */ - level?: LogLevel | false; + level?: MaybeLazy; /** A prefix to include in every log's message. */ prefix?: string; /** @@ -42,7 +44,7 @@ export interface LoggerOptions { } export class Logger implements LogWriter { - #level: LogLevel | false; + #level: MaybeLazy; #prefix: string | undefined; #attrs: Attributes | undefined; #writers: [LogWriter, ...LogWriter[]]; @@ -69,7 +71,11 @@ export class Logger implements LogWriter { } public get level() { - return this.#level; + return typeof this.#level === 'function' ? this.#level() : this.#level; + } + + public setLevel(level: MaybeLazy) { + this.#level = level; } public write( @@ -108,14 +114,14 @@ export class Logger implements LogWriter { public child(prefixOrAttrs: string | Attributes, prefix?: string): Logger { if (typeof prefixOrAttrs === 'string') { return new Logger({ - level: this.#level, + level: () => this.level, // inherits the parent level (yet can be changed on child only when using setLevel) prefix: (this.#prefix || '') + prefixOrAttrs, attrs: this.#attrs, writers: this.#writers, }); } return new Logger({ - level: this.#level, + level: () => this.level, // inherits the parent level (yet can be changed on child only when using setLevel) prefix: (this.#prefix || '') + (prefix || '') || undefined, attrs: { ...this.#attrs, ...prefixOrAttrs }, writers: this.#writers, diff --git a/packages/logger/src/utils.ts b/packages/logger/src/utils.ts index c7d6f9a98..8579174bc 100644 --- a/packages/logger/src/utils.ts +++ b/packages/logger/src/utils.ts @@ -28,9 +28,10 @@ export const logLevel: { [level in LogLevel]: number } = { }; export function shouldLog( - setLevel: LogLevel | false, + setLevel: MaybeLazy, loggingLevel: LogLevel, ): boolean { + setLevel = typeof setLevel === 'function' ? setLevel() : setLevel; return ( setLevel !== false && // logging is not disabled logLevel[setLevel] <= logLevel[loggingLevel] // and set log level is less than or equal to logging level diff --git a/packages/logger/tests/Logger.test.ts b/packages/logger/tests/Logger.test.ts index 5c91f7fe4..96e8a9e40 100644 --- a/packages/logger/tests/Logger.test.ts +++ b/packages/logger/tests/Logger.test.ts @@ -343,3 +343,73 @@ it.todo('should serialise aggregate errors'); it.todo('should serialise error causes'); it.todo('should serialise using the toJSON method'); + +it('should change log level', () => { + const [log, writer] = createTLogger(); + + log.info('hello'); + log.setLevel('warn'); + log.info('no hello'); + log.warn('yes hello'); + + expect(writer.logs).toMatchInlineSnapshot(` + [ + { + "level": "info", + "msg": "hello", + }, + { + "level": "warn", + "msg": "yes hello", + }, + ] + `); +}); + +it('should change root log level and propagate to child loggers', () => { + const [rootLog, writer] = createTLogger(); + + const childLog = rootLog.child('sub '); + + childLog.info('hello'); + rootLog.setLevel('warn'); + childLog.info('no hello'); + childLog.warn('yes hello'); + + expect(writer.logs).toMatchInlineSnapshot(` + [ + { + "level": "info", + "msg": "sub hello", + }, + { + "level": "warn", + "msg": "sub yes hello", + }, + ] + `); +}); + +it('should change child log level only on child', () => { + const [rootLog, writer] = createTLogger(); + + const childLog = rootLog.child('sub '); + + childLog.setLevel('warn'); + rootLog.info('yes hello'); // should still log because root didnt change + childLog.info('no hello'); + childLog.warn('yes hello'); + + expect(writer.logs).toMatchInlineSnapshot(` + [ + { + "level": "info", + "msg": "yes hello", + }, + { + "level": "warn", + "msg": "sub yes hello", + }, + ] + `); +}); From 90b75c3e6de52b5ed3fbd32998ffca3e17c2c6ab Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 10 Apr 2025 11:49:11 +0200 Subject: [PATCH 056/157] some cleanup --- packages/logger/src/utils.ts | 31 +------------------------------ packages/logger/src/writers.ts | 12 ++++++------ 2 files changed, 7 insertions(+), 36 deletions(-) diff --git a/packages/logger/src/utils.ts b/packages/logger/src/utils.ts index 8579174bc..c45727875 100644 --- a/packages/logger/src/utils.ts +++ b/packages/logger/src/utils.ts @@ -1,19 +1,8 @@ -import fastSafeStringify from 'fast-safe-stringify'; import { LogLevel } from './Logger'; export type MaybeLazy = T | (() => T); -export type AttributeValue = - | string - | number - | boolean - | { [key: string | number]: AttributeValue } - | AttributeValue[] - | Object // redundant, but this will allow _any_ object be the value - | null - | undefined - // TODO: remove `any`. this any will replace all other elements in the union, but is necessary for passing "interfaces" as attributes - | any; +export type AttributeValue = any; export type Attributes = | AttributeValue[] @@ -64,20 +53,6 @@ export function isPromise(val: unknown): val is Promise { ); } -/** An error safe JSON stringifyer. */ -export function jsonStringify(val: unknown, pretty?: boolean): string { - return fastSafeStringify( - val, - (_key, val) => { - if (val instanceof Error) { - return objectifyError(val); - } - return val; - }, - pretty ? 2 : undefined, - ); -} - /** Recursivelly unwrapps the lazy attributes and parses instances of classes. */ export function parseAttrs( attrs: MaybeLazy, @@ -176,10 +151,6 @@ function objectifyClass(val: unknown): Record { }; } -function objectifyError(err: Error) { - return objectifyClass(err); -} - export function getEnv(key: string): string | undefined { return ( globalThis.process?.env?.[key] || diff --git a/packages/logger/src/writers.ts b/packages/logger/src/writers.ts index 77d228dbe..b354fcd5c 100644 --- a/packages/logger/src/writers.ts +++ b/packages/logger/src/writers.ts @@ -1,10 +1,10 @@ +import fastSafeStringify from 'fast-safe-stringify'; import { LogLevel } from './Logger'; -import { - Attributes, - jsonStringify, - logLevelToString, - truthyEnv, -} from './utils'; +import { Attributes, logLevelToString, truthyEnv } from './utils'; + +export function jsonStringify(val: unknown, pretty?: boolean): string { + return fastSafeStringify(val, undefined, pretty ? 2 : undefined); +} export interface LogWriter { write( From 1117ded7f3151123be4c8cd4e173cb3e8b25a98f Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 10 Apr 2025 16:05:32 +0200 Subject: [PATCH 057/157] legacylogger tests and fixes --- packages/logger/src/LegacyLogger.ts | 14 +++- packages/logger/tests/LegacyLogger.test.ts | 90 ++++++++++++++++++++++ 2 files changed, 100 insertions(+), 4 deletions(-) create mode 100644 packages/logger/tests/LegacyLogger.test.ts diff --git a/packages/logger/src/LegacyLogger.ts b/packages/logger/src/LegacyLogger.ts index 3fbba96bf..e02abaacc 100644 --- a/packages/logger/src/LegacyLogger.ts +++ b/packages/logger/src/LegacyLogger.ts @@ -18,7 +18,11 @@ export class LegacyLogger { #log(level: LogLevel, ...[maybeMsgOrArg, ...restArgs]: any[]) { if (typeof maybeMsgOrArg === 'string') { - this.#logger.log(level, restArgs, maybeMsgOrArg); + if (restArgs.length) { + this.#logger.log(level, restArgs, maybeMsgOrArg); + } else { + this.#logger.log(level, maybeMsgOrArg); + } } else { if (restArgs.length) { this.#logger.log(level, [maybeMsgOrArg, ...restArgs]); @@ -53,9 +57,11 @@ export class LegacyLogger { } child(name: string | Record): LegacyLogger { - name = stringifyName(name); - if (this.#logger.prefix?.includes(name)) { - // TODO: why do we do this? + name = + stringifyName(name) + + // append space if object is strigified to space out the prefix + (typeof name === 'object' ? ' ' : ''); + if (this.#logger.prefix === name) { return this; } return LegacyLogger.from(this.#logger.child(name)); diff --git a/packages/logger/tests/LegacyLogger.test.ts b/packages/logger/tests/LegacyLogger.test.ts new file mode 100644 index 000000000..b00e4f805 --- /dev/null +++ b/packages/logger/tests/LegacyLogger.test.ts @@ -0,0 +1,90 @@ +import { LegacyLogger } from '@graphql-hive/logger'; +import { expect, it } from 'vitest'; +import { Logger, LoggerOptions } from '../src/Logger'; +import { MemoryLogWriter } from '../src/writers'; + +function createTLogger(opts?: Partial) { + const writer = new MemoryLogWriter(); + return [ + LegacyLogger.from( + new Logger({ ...opts, writers: opts?.writers ? opts.writers : [writer] }), + ), + writer, + ] as const; +} + +it('should correctly write legacy logger logs', () => { + const [log, writer] = createTLogger(); + + log.info('hello world'); + log.info({ hello: 'world' }); + log.info('hello', { wor: 'ld' }); + log.info('hello', [{ wor: 'ld' }]); + log.info('hello', { w: 'o' }, { rl: 'd' }); + log.info('hello', 'world'); + + log.child('child ').info('hello child'); + log.child({ chi: 'ld' }).info('hello chi ld'); + + expect(writer.logs).toMatchInlineSnapshot(` + [ + { + "level": "info", + "msg": "hello world", + }, + { + "attrs": { + "hello": "world", + }, + "level": "info", + }, + { + "attrs": [ + { + "wor": "ld", + }, + ], + "level": "info", + "msg": "hello", + }, + { + "attrs": [ + [ + { + "wor": "ld", + }, + ], + ], + "level": "info", + "msg": "hello", + }, + { + "attrs": [ + { + "w": "o", + }, + { + "rl": "d", + }, + ], + "level": "info", + "msg": "hello", + }, + { + "attrs": [ + "world", + ], + "level": "info", + "msg": "hello", + }, + { + "level": "info", + "msg": "child hello child", + }, + { + "level": "info", + "msg": "chi=ld hello chi ld", + }, + ] + `); +}); From 4ab616ed036516f9d0b9c627193954c75bce6495 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 10 Apr 2025 19:38:00 +0200 Subject: [PATCH 058/157] request logger --- packages/logger/package.json | 10 +++++++ packages/logger/src/Logger.ts | 15 ++++++++++ packages/logger/src/request.ts | 55 ++++++++++++++++++++++++++++++++++ tsconfig.json | 1 + 4 files changed, 81 insertions(+) create mode 100644 packages/logger/src/request.ts diff --git a/packages/logger/package.json b/packages/logger/package.json index 580b91f65..f5c29b5c0 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -29,6 +29,16 @@ "default": "./dist/index.js" } }, + "./request": { + "require": { + "types": "./dist/request.d.cts", + "default": "./dist/request.cjs" + }, + "import": { + "types": "./dist/request.d.ts", + "default": "./dist/request.js" + } + }, "./package.json": "./package.json" }, "files": [ diff --git a/packages/logger/src/Logger.ts b/packages/logger/src/Logger.ts index 30b24639c..0553f88b3 100644 --- a/packages/logger/src/Logger.ts +++ b/packages/logger/src/Logger.ts @@ -66,14 +66,29 @@ export class Logger implements LogWriter { this.#writers = opts.writers ?? [new ConsoleLogWriter()]; } + /** The prefix that's prepended to each log message. */ public get prefix() { return this.#prefix; } + /** + * The attributes that are added to each log. If the log itself contains + * attributes with keys existing in {@link attrs}, the log's attributes will + * override. + */ + public get attrs() { + return this.#attrs; + } + + /** The current {@link LogLevel} of the logger. You can change the level using the {@link setLevel} method. */ public get level() { return typeof this.#level === 'function' ? this.#level() : this.#level; } + /** + * Sets the new {@link LogLevel} of the logger. All subsequent logs, and {@link child child loggers} whose + * level did not change, will respect the new level. + */ public setLevel(level: MaybeLazy) { this.#level = level; } diff --git a/packages/logger/src/request.ts b/packages/logger/src/request.ts new file mode 100644 index 000000000..5e80a8326 --- /dev/null +++ b/packages/logger/src/request.ts @@ -0,0 +1,55 @@ +import { Logger } from './Logger'; + +// TODO: write tests + +export const requestIdByRequest = new WeakMap(); + +/** The getter function that extracts the requestID from the {@link request} or creates a new one if none-exist. */ +export type GetRequestID = (request: Request) => string; + +/** + * Creates a child {@link Logger} under the {@link log given logger} for the {@link request}. + * + * Request's ID will be stored in the {@link requestIdByRequest} weak map; meaning, all + * subsequent calls to this function with the same {@link request} will return the same ID. + * + * The {@link getId} argument will be used to create a new ID if the {@link request} does not + * have one. The convention is to the `X-Request-ID` header or create a new ID which is an + * UUID v4. + * + * On the other hand, if the {@link getId} argument is omitted, the {@link requestIdByRequest} weak + * map will be looked up, and if there is no ID stored for the {@link request} - the function + * will not attempt to create a new ID and will just return the same {@link log logger}. + * + * The request ID will be added to the logger attributes under the `requestId` key and + * will be logged in every subsequent log. + */ +export function loggerForRequest(log: Logger, request: Request): Logger; +export function loggerForRequest( + log: Logger, + request: Request, + getId: GetRequestID, +): Logger; +export function loggerForRequest( + log: Logger, + request: Request, + getId?: GetRequestID, +): Logger { + let requestId = requestIdByRequest.get(request); + if (!requestId) { + if (getId === undefined) { + return log; + } + requestId = getId(request); + requestIdByRequest.set(request, requestId); + } + if ( + log.attrs && + 'requestId' in log.attrs && + log.attrs['requestId'] === requestId + ) { + // this logger is already a child that contains this request id, no need to create a new one + return log; + } + return log.child({ requestId }); +} diff --git a/tsconfig.json b/tsconfig.json index 15642b6da..01774de61 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -62,6 +62,7 @@ "@graphql-tools/wrap": ["./packages/wrap/src/index.ts"], "@graphql-tools/executor-*": ["./packages/executors/*/src/index.ts"], "@graphql-hive/logger": ["./packages/logger/src/index.ts"], + "@graphql-hive/logger/request": ["./packages/logger/src/request.ts"], "@graphql-hive/logger-json": ["./packages/logger-json/src/index.ts"], "@graphql-hive/logger-winston": [ "./packages/logger-winston/src/index.ts" From 35d1d8c9364029c6653d24b72df59a2c722bd7cd Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 10 Apr 2025 19:44:17 +0200 Subject: [PATCH 059/157] move over wrapfetchwith hooks --- .../src/federation/supergraph.ts | 11 +- packages/fusion-runtime/src/utils.ts | 84 ++++----------- packages/runtime/package.json | 1 + packages/runtime/src/createGatewayRuntime.ts | 3 +- packages/runtime/src/plugins/useFetchDebug.ts | 3 +- packages/runtime/src/plugins/useRequestId.ts | 25 +++-- .../src/plugins/useRetryOnSchemaReload.ts | 11 +- packages/runtime/src/wrapFetchWithHooks.ts | 101 ++++++++++++++++++ .../runtime/tests/wrapFetchWithHooks.test.ts | 37 +++++++ yarn.lock | 1 + 10 files changed, 184 insertions(+), 93 deletions(-) create mode 100644 packages/runtime/src/wrapFetchWithHooks.ts create mode 100644 packages/runtime/tests/wrapFetchWithHooks.test.ts diff --git a/packages/fusion-runtime/src/federation/supergraph.ts b/packages/fusion-runtime/src/federation/supergraph.ts index bbac6d3da..2fe830e5e 100644 --- a/packages/fusion-runtime/src/federation/supergraph.ts +++ b/packages/fusion-runtime/src/federation/supergraph.ts @@ -1,8 +1,7 @@ -import { LegacyLogger } from '@graphql-hive/logger'; +import { LegacyLogger, Logger } from '@graphql-hive/logger'; import type { YamlConfig } from '@graphql-mesh/types'; import { getInContextSDK, - requestIdByRequest, resolveAdditionalResolversWithoutImport, } from '@graphql-mesh/utils'; import type { @@ -307,13 +306,7 @@ export const handleFederationSupergraph: UnifiedGraphHandler = function ({ delegationPlanBuilder = newDelegationPlanBuilder; } const onDelegationPlanDoneHooks: OnDelegationPlanDoneHook[] = []; - let requestId: string | undefined; - if (context?.request) { - requestId = requestIdByRequest.get(context.request); - if (requestId) { - log = log.child({ requestId }); - } - } + let log = context.log as Logger; if (sourceSubschema.name) { log = log.child({ subgraph: sourceSubschema.name, diff --git a/packages/fusion-runtime/src/utils.ts b/packages/fusion-runtime/src/utils.ts index 17f967e0e..3d5d17df2 100644 --- a/packages/fusion-runtime/src/utils.ts +++ b/packages/fusion-runtime/src/utils.ts @@ -1,5 +1,6 @@ import { getInstrumented } from '@envelop/instrumentation'; -import { LegacyLogger, type Logger } from '@graphql-hive/logger'; +import type { Logger } from '@graphql-hive/logger'; +import { loggerForRequest } from '@graphql-hive/logger/request'; import { defaultPrintFn, type Transport, @@ -8,12 +9,7 @@ import { type TransportGetSubgraphExecutor, type TransportGetSubgraphExecutorOptions, } from '@graphql-mesh/transport-common'; -import { - isDisposable, - iterateAsync, - loggerForExecutionRequest, - requestIdByRequest, -} from '@graphql-mesh/utils'; +import { isDisposable, iterateAsync } from '@graphql-mesh/utils'; import { getBatchingExecutor } from '@graphql-tools/batch-execute'; import { DelegationPlanBuilder, @@ -119,15 +115,8 @@ function getTransportExecutor({ transports?: Transports; getDisposeReason?: () => GraphQLError | undefined; }): MaybePromise { - // TODO const kind = transportEntry?.kind || ''; - let log = transportContext.log; - if (log) { - if (subgraphName) { - log = log.child({ subgraph: subgraphName }); - } - log.debug(`Loading transport "${kind}"`); - } + transportContext.log.debug(`Loading transport "${kind}"`); return handleMaybePromise( () => typeof transports === 'function' ? transports(kind) : transports[kind], @@ -214,19 +203,18 @@ export function getOnSubgraphExecute({ let executor: Executor | undefined = subgraphExecutorMap.get(subgraphName); // If the executor is not initialized yet, initialize it if (executor == null) { - let log = transportContext.log; - if (log) { - const requestId = requestIdByRequest.get( - executionRequest.context?.request, - ); - if (requestId) { - log = log.child({ requestId }); - } - if (subgraphName) { - log = log.child({ subgraph: subgraphName }); - } - log.debug('Initializing executor'); + let log = executionRequest.context?.request + ? loggerForRequest( + transportContext.log, + executionRequest.context.request, + ) + : transportContext.log; + if (subgraphName) { + log = log.child({ subgraph: subgraphName }); } + // overwrite the log in the transport context because now it contains more details + transportContext.log = log; + log.debug('Initializing executor'); // Lazy executor that loads transport executor on demand executor = function lazyExecutor(subgraphExecReq: ExecutionRequest) { return handleMaybePromise( @@ -317,21 +305,7 @@ export function wrapExecutorWithHooks({ baseExecutionRequest.rootValue = { executionRequest: baseExecutionRequest, }; - - const requestId = - baseExecutionRequest.context?.request && - requestIdByRequest.get(baseExecutionRequest.context.request); - let execReqLogger = transportContext.log; - if (execReqLogger) { - if (requestId) { - execReqLogger = execReqLogger.child({ requestId }); - } - loggerForExecutionRequest.set( - baseExecutionRequest, - LegacyLogger.from(execReqLogger), - ); - } - execReqLogger = execReqLogger?.child({ subgraph: subgraphName }); + const log = transportContext.log.child({ subgraph: subgraphName }); if (onSubgraphExecuteHooks.length === 0) { return baseExecutor(baseExecutionRequest); } @@ -357,11 +331,10 @@ export function wrapExecutorWithHooks({ }, executor, setExecutor(newExecutor) { - execReqLogger?.debug('executor has been updated'); + log?.debug('executor has been updated'); executor = newExecutor; }, - requestId, - log: execReqLogger, + log: log, }), onSubgraphExecuteDoneHooks, ), @@ -381,10 +354,7 @@ export function wrapExecutorWithHooks({ onSubgraphExecuteDoneHook({ result: currentResult, setResult(newResult: ExecutionResult) { - execReqLogger?.debug( - 'overriding result with: ', - newResult, - ); + log?.debug('overriding result with: ', newResult); currentResult = newResult; }, }), @@ -424,10 +394,7 @@ export function wrapExecutorWithHooks({ onNext({ result: currentResult, setResult: (res) => { - execReqLogger?.debug( - 'overriding result with: ', - res, - ); + log?.debug('overriding result with: ', res); currentResult = res; }, @@ -476,7 +443,6 @@ export interface OnSubgraphExecutePayload { setExecutionRequest(executionRequest: ExecutionRequest): void; executor: Executor; setExecutor(executor: Executor): void; - requestId?: string; log: Logger; } @@ -518,7 +484,6 @@ export interface OnDelegationPlanHookPayload { fragments: Record; fieldNodes: SelectionNode[]; context: TContext; - requestId?: string; log: Logger; info?: GraphQLResolveInfo; delegationPlanBuilder: DelegationPlanBuilder; @@ -555,7 +520,6 @@ export interface OnDelegationStageExecutePayload { typeName: string; - requestId?: string; log: Logger; } @@ -606,13 +570,6 @@ export function wrapMergedTypeResolver>( log: Logger, ): MergedTypeResolver { return (object, context, info, subschema, selectionSet, key, type) => { - let requestId: string | undefined; - if (log && context['request']) { - requestId = requestIdByRequest.get(context['request']); - if (requestId) { - log = log.child({ requestId }); - } - } if (subschema.name) { log = log.child({ subgraph: subschema.name }); } @@ -633,7 +590,6 @@ export function wrapMergedTypeResolver>( key, typeName, type, - requestId, log, resolver, setResolver, diff --git a/packages/runtime/package.json b/packages/runtime/package.json index ee4351f17..f1223ff11 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -47,6 +47,7 @@ "@envelop/core": "^5.2.3", "@envelop/disable-introspection": "^7.0.0", "@envelop/generic-auth": "^9.0.0", + "@envelop/instrumentation": "^1.0.0", "@graphql-hive/core": "^0.12.0", "@graphql-hive/logger": "workspace:^", "@graphql-hive/logger-json": "workspace:^", diff --git a/packages/runtime/src/createGatewayRuntime.ts b/packages/runtime/src/createGatewayRuntime.ts index c4a1b5708..c8e0235e7 100644 --- a/packages/runtime/src/createGatewayRuntime.ts +++ b/packages/runtime/src/createGatewayRuntime.ts @@ -38,7 +38,6 @@ import { getInContextSDK, isDisposable, isUrl, - wrapFetchWithHooks, } from '@graphql-mesh/utils'; import { batchDelegateToSchema } from '@graphql-tools/batch-delegate'; import { @@ -127,6 +126,7 @@ import { getExecuteFnFromExecutor, wrapCacheWithHooks, } from './utils'; +import { wrapFetchWithHooks } from './wrapFetchWithHooks'; // TODO: this type export is not properly accessible from graphql-yoga // "graphql-yoga/typings/plugins/use-graphiql.js" is an illegal path @@ -162,7 +162,6 @@ export function createGatewayRuntime< const wrappedFetchFn = wrapFetchWithHooks( onFetchHooks, () => instrumentation, - LegacyLogger.from(log), ); const wrappedCache: KeyValueCache | undefined = config.cache ? wrapCacheWithHooks({ diff --git a/packages/runtime/src/plugins/useFetchDebug.ts b/packages/runtime/src/plugins/useFetchDebug.ts index fda8bbec0..80fc5014f 100644 --- a/packages/runtime/src/plugins/useFetchDebug.ts +++ b/packages/runtime/src/plugins/useFetchDebug.ts @@ -15,8 +15,7 @@ export function useFetchDebug< log.debug( () => ({ url, - ...(options || {}), - body: options?.body, + body: options?.body?.toString(), headers: options?.headers, signal: options?.signal?.aborted ? options?.signal?.reason : false, }), diff --git a/packages/runtime/src/plugins/useRequestId.ts b/packages/runtime/src/plugins/useRequestId.ts index 07fea8911..ee62dc9b2 100644 --- a/packages/runtime/src/plugins/useRequestId.ts +++ b/packages/runtime/src/plugins/useRequestId.ts @@ -1,4 +1,7 @@ -import { requestIdByRequest } from '@graphql-mesh/utils'; +import { + loggerForRequest, + requestIdByRequest, +} from '@graphql-hive/logger/request'; import { FetchAPI } from '@whatwg-node/server'; import type { GatewayContext, GatewayPlugin } from '../types'; @@ -49,15 +52,17 @@ export function useRequestId>( requestIdByRequest.set(request, requestId); }, onContextBuilding({ context, extendContext }) { - if (context?.request) { - const requestId = requestIdByRequest.get(context.request); - if (requestId) { - extendContext( - // @ts-expect-error TODO: typescript is acting up here - { log: context.log.child({ requestId }) }, - ); - } - } + extendContext( + // @ts-expect-error TODO: typescript is acting up here + { + log: loggerForRequest(context.log, context.request, () => { + throw new Error( + "Request ID must've already been created but is not found", + ); + // because we are using the logger's requestIdByRequest map + }), + }, + ); }, onFetch({ context, options, setOptions }) { if (context?.request) { diff --git a/packages/runtime/src/plugins/useRetryOnSchemaReload.ts b/packages/runtime/src/plugins/useRetryOnSchemaReload.ts index adddb51ab..f826a98ff 100644 --- a/packages/runtime/src/plugins/useRetryOnSchemaReload.ts +++ b/packages/runtime/src/plugins/useRetryOnSchemaReload.ts @@ -1,5 +1,5 @@ import type { Logger } from '@graphql-hive/logger'; -import { requestIdByRequest } from '@graphql-mesh/utils'; +import { loggerForRequest } from '@graphql-hive/logger/request'; import type { MaybeAsyncIterable } from '@graphql-tools/utils'; import { handleMaybePromise, @@ -41,11 +41,10 @@ export function useRetryOnSchemaReload< execHandler && result?.errors?.some((e) => e.extensions?.['code'] === 'SCHEMA_RELOAD') ) { - let log = logForRequest.get(request)!; // must exist at this point - const requestId = requestIdByRequest.get(request); - if (requestId) { - log = log.child({ requestId }); - } + const log = loggerForRequest( + logForRequest.get(request)!, // must exist at this point + request, + ); log.info( 'The operation has been aborted after the supergraph schema reloaded, retrying the operation...', ); diff --git a/packages/runtime/src/wrapFetchWithHooks.ts b/packages/runtime/src/wrapFetchWithHooks.ts new file mode 100644 index 000000000..291c6a796 --- /dev/null +++ b/packages/runtime/src/wrapFetchWithHooks.ts @@ -0,0 +1,101 @@ +import { getInstrumented } from '@envelop/instrumentation'; +import type { + MeshFetch, + OnFetchHook, + OnFetchHookDone, +} from '@graphql-mesh/types'; +import { type ExecutionRequest, type MaybePromise } from '@graphql-tools/utils'; +import { handleMaybePromise, iterateAsync } from '@whatwg-node/promise-helpers'; + +export type FetchInstrumentation = { + fetch?: ( + payload: { executionRequest?: ExecutionRequest }, + wrapped: () => MaybePromise, + ) => MaybePromise; +}; + +export function wrapFetchWithHooks( + onFetchHooks: OnFetchHook[], + instrumentation?: () => FetchInstrumentation | undefined, +): MeshFetch { + let wrappedFetchFn = function wrappedFetchFn(url, options, context, info) { + let fetchFn: MeshFetch; + let response$: MaybePromise; + const onFetchDoneHooks: OnFetchHookDone[] = []; + return handleMaybePromise( + () => + iterateAsync( + onFetchHooks, + (onFetch, endEarly) => + onFetch({ + fetchFn, + setFetchFn(newFetchFn) { + fetchFn = newFetchFn; + }, + url, + setURL(newUrl) { + url = String(newUrl); + }, + // @ts-expect-error TODO: why? + options, + setOptions(newOptions) { + options = newOptions; + }, + context, + // @ts-expect-error TODO: why? + info, + get executionRequest() { + return info?.executionRequest; + }, + endResponse(newResponse) { + response$ = newResponse; + endEarly(); + }, + }), + onFetchDoneHooks, + ), + function handleIterationResult() { + if (response$) { + return response$; + } + return handleMaybePromise( + () => fetchFn(url, options, context, info), + function (response: Response) { + return handleMaybePromise( + () => + iterateAsync(onFetchDoneHooks, (onFetchDone) => + onFetchDone({ + response, + setResponse(newResponse) { + response = newResponse; + }, + }), + ), + function handleOnFetchDone() { + return response; + }, + ); + }, + ); + }, + ); + } as MeshFetch; + + if (instrumentation) { + const originalWrappedFetch = wrappedFetchFn; + wrappedFetchFn = function wrappedFetchFn(url, options, context, info) { + const fetchInstrument = instrumentation()?.fetch; + const instrumentedFetch = fetchInstrument + ? getInstrumented({ + get executionRequest() { + return info?.executionRequest; + }, + }).asyncFn(fetchInstrument, originalWrappedFetch) + : originalWrappedFetch; + + return instrumentedFetch(url, options, context, info); + }; + } + + return wrappedFetchFn; +} diff --git a/packages/runtime/tests/wrapFetchWithHooks.test.ts b/packages/runtime/tests/wrapFetchWithHooks.test.ts new file mode 100644 index 000000000..bae527058 --- /dev/null +++ b/packages/runtime/tests/wrapFetchWithHooks.test.ts @@ -0,0 +1,37 @@ +import { createServerAdapter, Response } from '@whatwg-node/server'; +import type { GraphQLResolveInfo } from 'graphql'; +import { expect, it } from 'vitest'; +import { + wrapFetchWithHooks, + type FetchInstrumentation, +} from '../src/wrapFetchWithHooks'; + +it('should wrap fetch instrumentation', async () => { + await using adapter = createServerAdapter(() => + Response.json({ hello: 'world' }), + ); + let receivedExecutionRequest; + const fetchInstrumentation: FetchInstrumentation = { + fetch: async ({ executionRequest }, wrapped) => { + receivedExecutionRequest = executionRequest; + await wrapped(); + }, + }; + const wrappedFetch = wrapFetchWithHooks( + [ + ({ setFetchFn }) => { + setFetchFn( + // @ts-expect-error TODO: MeshFetch is not compatible with @whatwg-node/server fetch + adapter.fetch, + ); + }, + ], + () => fetchInstrumentation, + ); + const executionRequest = {}; + const res = await wrappedFetch('http://localhost:4000', {}, {}, { + executionRequest, + } as GraphQLResolveInfo); + expect(await res.json()).toEqual({ hello: 'world' }); + expect(receivedExecutionRequest).toBe(executionRequest); +}); diff --git a/yarn.lock b/yarn.lock index db2feb0b4..35d5c4abf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3941,6 +3941,7 @@ __metadata: "@envelop/core": "npm:^5.2.3" "@envelop/disable-introspection": "npm:^7.0.0" "@envelop/generic-auth": "npm:^9.0.0" + "@envelop/instrumentation": "npm:^1.0.0" "@graphql-hive/core": "npm:^0.12.0" "@graphql-hive/logger": "workspace:^" "@graphql-hive/logger-json": "workspace:^" From 06e8faeb576125a487b5e065e90b38bbdf4f515e Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 10 Apr 2025 19:44:19 +0200 Subject: [PATCH 060/157] todo --- packages/runtime/src/wrapFetchWithHooks.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/runtime/src/wrapFetchWithHooks.ts b/packages/runtime/src/wrapFetchWithHooks.ts index 291c6a796..d5755f0eb 100644 --- a/packages/runtime/src/wrapFetchWithHooks.ts +++ b/packages/runtime/src/wrapFetchWithHooks.ts @@ -15,7 +15,7 @@ export type FetchInstrumentation = { }; export function wrapFetchWithHooks( - onFetchHooks: OnFetchHook[], + // onFetchHooks: OnFetchHook[], TODO: move over onfetchhook types with new signature instrumentation?: () => FetchInstrumentation | undefined, ): MeshFetch { let wrappedFetchFn = function wrappedFetchFn(url, options, context, info) { From 66747a4ca507cf758b248773d12c3741344b31fa Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 10 Apr 2025 17:44:55 +0000 Subject: [PATCH 061/157] chore(dependencies): updated changesets for modified dependencies --- .changeset/@graphql-hive_gateway-runtime-1030-dependencies.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/@graphql-hive_gateway-runtime-1030-dependencies.md b/.changeset/@graphql-hive_gateway-runtime-1030-dependencies.md index c2dbac50f..1c3382fe5 100644 --- a/.changeset/@graphql-hive_gateway-runtime-1030-dependencies.md +++ b/.changeset/@graphql-hive_gateway-runtime-1030-dependencies.md @@ -4,4 +4,5 @@ dependencies updates: +- Added dependency [`@envelop/instrumentation@^1.0.0` ↗︎](https://www.npmjs.com/package/@envelop/instrumentation/v/1.0.0) (to `dependencies`) - Added dependency [`@graphql-hive/logger@workspace:^` ↗︎](https://www.npmjs.com/package/@graphql-hive/logger/v/workspace:^) (to `dependencies`) From a8dd58e95e5be743bf66bbc14a3ca943ea9abd26 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Fri, 11 Apr 2025 19:31:00 +0200 Subject: [PATCH 062/157] fix wrap fetch --- packages/plugins/prometheus/src/index.ts | 3 +- packages/runtime/src/createGatewayRuntime.ts | 8 ++-- .../runtime/src/plugins/useCustomAgent.ts | 7 +++- .../src/plugins/usePropagateHeaders.ts | 3 +- packages/runtime/src/types.ts | 38 ++++++++++++++++++- packages/runtime/src/wrapFetchWithHooks.ts | 15 ++++---- .../runtime/tests/contentEncoding.test.ts | 7 +++- 7 files changed, 58 insertions(+), 23 deletions(-) diff --git a/packages/plugins/prometheus/src/index.ts b/packages/plugins/prometheus/src/index.ts index 11504023e..8b75884b1 100644 --- a/packages/plugins/prometheus/src/index.ts +++ b/packages/plugins/prometheus/src/index.ts @@ -1,4 +1,4 @@ -import { type GatewayPlugin } from '@graphql-hive/gateway-runtime'; +import type { GatewayPlugin, OnFetchHook } from '@graphql-hive/gateway-runtime'; import type { Logger } from '@graphql-hive/logger'; import type { OnSubgraphExecuteHook } from '@graphql-mesh/fusion-runtime'; import type { TransportEntry } from '@graphql-mesh/transport-common'; @@ -6,7 +6,6 @@ import type { ImportFn, MeshFetchRequestInit, MeshPlugin, - OnFetchHook, } from '@graphql-mesh/types'; import { defaultImportFn, diff --git a/packages/runtime/src/createGatewayRuntime.ts b/packages/runtime/src/createGatewayRuntime.ts index c8e0235e7..8f7fd84e2 100644 --- a/packages/runtime/src/createGatewayRuntime.ts +++ b/packages/runtime/src/createGatewayRuntime.ts @@ -27,11 +27,7 @@ import { import { useHmacUpstreamSignature } from '@graphql-mesh/hmac-upstream-signature'; import useMeshResponseCache from '@graphql-mesh/plugin-response-cache'; import { TransportContext } from '@graphql-mesh/transport-common'; -import type { - KeyValueCache, - OnDelegateHook, - OnFetchHook, -} from '@graphql-mesh/types'; +import type { KeyValueCache, OnDelegateHook } from '@graphql-mesh/types'; import { dispose, getHeadersObj, @@ -118,6 +114,7 @@ import type { OnCacheDeleteHook, OnCacheGetHook, OnCacheSetHook, + OnFetchHook, UnifiedGraphConfig, } from './types'; import { @@ -161,6 +158,7 @@ export function createGatewayRuntime< const onCacheDeleteHooks: OnCacheDeleteHook[] = []; const wrappedFetchFn = wrapFetchWithHooks( onFetchHooks, + log, () => instrumentation, ); const wrappedCache: KeyValueCache | undefined = config.cache diff --git a/packages/runtime/src/plugins/useCustomAgent.ts b/packages/runtime/src/plugins/useCustomAgent.ts index 8211b3dc6..3f61a2cb5 100644 --- a/packages/runtime/src/plugins/useCustomAgent.ts +++ b/packages/runtime/src/plugins/useCustomAgent.ts @@ -2,8 +2,11 @@ import type { Agent as HttpAgent } from 'node:http'; // eslint-disable-next-line import/no-nodejs-modules import type { Agent as HttpsAgent } from 'node:https'; -import type { OnFetchHookPayload } from '@graphql-mesh/types'; -import type { GatewayContext, GatewayPlugin } from '../types'; +import type { + GatewayContext, + GatewayPlugin, + OnFetchHookPayload, +} from '../types'; export type AgentFactory = ( payload: OnFetchHookPayload< diff --git a/packages/runtime/src/plugins/usePropagateHeaders.ts b/packages/runtime/src/plugins/usePropagateHeaders.ts index 939329a0f..1e0711401 100644 --- a/packages/runtime/src/plugins/usePropagateHeaders.ts +++ b/packages/runtime/src/plugins/usePropagateHeaders.ts @@ -1,7 +1,6 @@ import { subgraphNameByExecutionRequest } from '@graphql-mesh/fusion-runtime'; -import type { OnFetchHookDone } from '@graphql-mesh/types'; import { handleMaybePromise } from '@whatwg-node/promise-helpers'; -import type { GatewayPlugin } from '../types'; +import type { GatewayPlugin, OnFetchHookDone } from '../types'; interface FromClientToSubgraphsPayload { request: Request; diff --git a/packages/runtime/src/types.ts b/packages/runtime/src/types.ts index 366b4eb6b..c1d00bcf7 100644 --- a/packages/runtime/src/types.ts +++ b/packages/runtime/src/types.ts @@ -13,11 +13,12 @@ import type { ResponseCacheConfig } from '@graphql-mesh/plugin-response-cache'; import type { KeyValueCache, MeshFetch, - OnFetchHook, + MeshFetchRequestInit, } from '@graphql-mesh/types'; import type { FetchInstrumentation } from '@graphql-mesh/utils'; import type { HTTPExecutorOptions } from '@graphql-tools/executor-http'; import type { + ExecutionRequest, IResolvers, MaybePromise, TypeSource, @@ -35,6 +36,7 @@ import type { Plugin as YogaPlugin, YogaServerOptions, } from 'graphql-yoga'; +import { GraphQLResolveInfo } from 'graphql/type'; import type { UnifiedGraphConfig } from './handleUnifiedGraphConfig'; import type { UseContentEncodingOpts } from './plugins/useContentEncoding'; import type { AgentFactory } from './plugins/useCustomAgent'; @@ -96,7 +98,7 @@ export type GatewayPlugin< TContext extends Record = Record, > = YogaPlugin & GatewayContext & TContext> & UnifiedGraphPlugin & GatewayContext & TContext> & { - onFetch?: OnFetchHook & GatewayContext & TContext>; + onFetch?: OnFetchHook & TContext>; onCacheGet?: OnCacheGetHook; onCacheSet?: OnCacheSetHook; onCacheDelete?: OnCacheDeleteHook; @@ -112,6 +114,38 @@ export type GatewayPlugin< >; }; +export interface OnFetchHookPayload { + url: string; + setURL(url: URL | string): void; + options: MeshFetchRequestInit; + setOptions(options: MeshFetchRequestInit): void; + /** + * The context is not available in cases where "fetch" is done in + * order to pull a supergraph or do some internal work. + * + * The logger will be available in all cases. + */ + context: (GatewayContext & TContext) | { log: Logger }; + info: GraphQLResolveInfo; + fetchFn: MeshFetch; + setFetchFn: (fetchFn: MeshFetch) => void; + executionRequest?: ExecutionRequest; + endResponse: (response$: MaybePromise) => void; +} + +export interface OnFetchHookDonePayload { + response: Response; + setResponse: (response: Response) => void; +} + +export type OnFetchHookDone = ( + payload: OnFetchHookDonePayload, +) => MaybePromise; + +export type OnFetchHook = ( + payload: OnFetchHookPayload, +) => MaybePromise; + export type OnCacheGetHook = ( payload: OnCacheGetHookEventPayload, ) => MaybePromise; diff --git a/packages/runtime/src/wrapFetchWithHooks.ts b/packages/runtime/src/wrapFetchWithHooks.ts index d5755f0eb..c1f1dc0f8 100644 --- a/packages/runtime/src/wrapFetchWithHooks.ts +++ b/packages/runtime/src/wrapFetchWithHooks.ts @@ -1,11 +1,9 @@ import { getInstrumented } from '@envelop/instrumentation'; -import type { - MeshFetch, - OnFetchHook, - OnFetchHookDone, -} from '@graphql-mesh/types'; -import { type ExecutionRequest, type MaybePromise } from '@graphql-tools/utils'; +import type { Logger } from '@graphql-hive/logger'; +import type { MeshFetch } from '@graphql-mesh/types'; +import type { ExecutionRequest, MaybePromise } from '@graphql-tools/utils'; import { handleMaybePromise, iterateAsync } from '@whatwg-node/promise-helpers'; +import { OnFetchHook, OnFetchHookDone } from './types'; export type FetchInstrumentation = { fetch?: ( @@ -15,7 +13,8 @@ export type FetchInstrumentation = { }; export function wrapFetchWithHooks( - // onFetchHooks: OnFetchHook[], TODO: move over onfetchhook types with new signature + onFetchHooks: OnFetchHook[], + log: Logger, instrumentation?: () => FetchInstrumentation | undefined, ): MeshFetch { let wrappedFetchFn = function wrappedFetchFn(url, options, context, info) { @@ -41,7 +40,7 @@ export function wrapFetchWithHooks( setOptions(newOptions) { options = newOptions; }, - context, + context: { log, ...context }, // @ts-expect-error TODO: why? info, get executionRequest() { diff --git a/packages/runtime/tests/contentEncoding.test.ts b/packages/runtime/tests/contentEncoding.test.ts index 84443e376..317455fb1 100644 --- a/packages/runtime/tests/contentEncoding.test.ts +++ b/packages/runtime/tests/contentEncoding.test.ts @@ -1,5 +1,4 @@ import { getUnifiedGraphGracefully } from '@graphql-mesh/fusion-composition'; -import type { OnFetchHookDonePayload } from '@graphql-mesh/types'; import { getSupportedEncodings, useContentEncoding } from '@whatwg-node/server'; import { createSchema, @@ -8,7 +7,11 @@ import { type YogaInitialContext, } from 'graphql-yoga'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { createGatewayRuntime, useCustomFetch } from '../src/index'; +import { + createGatewayRuntime, + OnFetchHookDonePayload, + useCustomFetch, +} from '../src/index'; describe('contentEncoding', () => { const fooResolver = vi.fn((_, __, _context: YogaInitialContext) => { From ed29fbd0409165f2d708925b0739a3ddcf5fb4f3 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Wed, 16 Apr 2025 20:05:02 +0200 Subject: [PATCH 063/157] fix types and provide legacy logger under "logger" for some components --- packages/fusion-runtime/tests/polling.test.ts | 19 +++--- packages/fusion-runtime/tests/utils.ts | 10 ++- packages/runtime/src/createGatewayRuntime.ts | 10 ++- packages/runtime/src/getProxyExecutor.ts | 6 +- .../src/plugins/usePropagateHeaders.ts | 5 +- packages/runtime/src/plugins/useRequestId.ts | 17 +++--- .../runtime/src/plugins/useUpstreamCancel.ts | 2 +- packages/runtime/src/types.ts | 3 + packages/runtime/src/wrapFetchWithHooks.ts | 3 +- packages/runtime/tests/graphos.test.ts | 61 ++++++++----------- .../runtime/tests/wrapFetchWithHooks.test.ts | 2 + packages/transports/common/src/types.ts | 8 ++- 12 files changed, 83 insertions(+), 63 deletions(-) diff --git a/packages/fusion-runtime/tests/polling.test.ts b/packages/fusion-runtime/tests/polling.test.ts index ba4d612d3..ebba31f1c 100644 --- a/packages/fusion-runtime/tests/polling.test.ts +++ b/packages/fusion-runtime/tests/polling.test.ts @@ -1,5 +1,5 @@ import { setTimeout } from 'timers/promises'; -import { Logger } from '@graphql-hive/logger'; +import { LegacyLogger, Logger } from '@graphql-hive/logger'; import { getUnifiedGraphGracefully } from '@graphql-mesh/fusion-composition'; import { getExecutorForUnifiedGraph } from '@graphql-mesh/fusion-runtime'; import { @@ -61,13 +61,12 @@ describe('Polling', () => { const disposeFn = vi.fn(); + const log = new Logger({ level: false }); await using manager = new UnifiedGraphManager({ getUnifiedGraph: unifiedGraphFetcher, pollingInterval: pollingInterval, batch: false, - transportContext: { - log: new Logger({ level: false }), - }, + transportContext: { log, logger: LegacyLogger.from(log) }, transports() { return { getSubgraphExecutor() { @@ -202,13 +201,12 @@ describe('Polling', () => { }, ]); }); + const log = new Logger({ level: false }); await using manager = new UnifiedGraphManager({ getUnifiedGraph: unifiedGraphFetcher, pollingInterval: pollingInterval, batch: false, - transportContext: { - log: new Logger({ level: false }), - }, + transportContext: { log, logger: LegacyLogger.from(log) }, transports() { return { getSubgraphExecutor() { @@ -301,12 +299,11 @@ describe('Polling', () => { ]); }); let disposeFn = vi.fn(); + const log = new Logger({ level: false }); await using executor = getExecutorForUnifiedGraph({ getUnifiedGraph: unifiedGraphFetcher, pollingInterval: 1000, - transportContext: { - log: new Logger({ level: false }), - }, + transportContext: { log, logger: LegacyLogger.from(log) }, transports() { return { getSubgraphExecutor() { @@ -387,7 +384,7 @@ describe('Polling', () => { await using executor = getExecutorForUnifiedGraph({ getUnifiedGraph: unifiedGraphFetcher, pollingInterval: 10_000, - transportContext: { log }, + transportContext: { log, logger: LegacyLogger.from(log) }, transports() { log.debug('transports'); return { diff --git a/packages/fusion-runtime/tests/utils.ts b/packages/fusion-runtime/tests/utils.ts index deaa00d39..fac6acb16 100644 --- a/packages/fusion-runtime/tests/utils.ts +++ b/packages/fusion-runtime/tests/utils.ts @@ -1,4 +1,4 @@ -import { Logger } from '@graphql-hive/logger'; +import { LegacyLogger, Logger } from '@graphql-hive/logger'; import { getUnifiedGraphGracefully, type SubgraphConfig, @@ -22,10 +22,12 @@ import { } from '../src/unifiedGraphManager'; export function composeAndGetPublicSchema(subgraphs: SubgraphConfig[]) { + const log = new Logger({ level: false }); const manager = new UnifiedGraphManager({ getUnifiedGraph: () => getUnifiedGraphGracefully(subgraphs), transportContext: { - log: new Logger({ level: false }), + log, + logger: LegacyLogger.from(log), }, transports() { return { @@ -48,10 +50,12 @@ export function composeAndGetExecutor( subgraphs: SubgraphConfig[], opts?: Partial>, ) { + const log = new Logger({ level: false }); const manager = new UnifiedGraphManager({ getUnifiedGraph: () => getUnifiedGraphGracefully(subgraphs), transportContext: { - log: new Logger({ level: false }), + log, + logger: LegacyLogger.from(log), }, transports() { return { diff --git a/packages/runtime/src/createGatewayRuntime.ts b/packages/runtime/src/createGatewayRuntime.ts index 8f7fd84e2..803011b7f 100644 --- a/packages/runtime/src/createGatewayRuntime.ts +++ b/packages/runtime/src/createGatewayRuntime.ts @@ -476,7 +476,10 @@ export function createGatewayRuntime< const onSubgraphExecute = getOnSubgraphExecute({ onSubgraphExecuteHooks, ...(config.transports ? { transports: config.transports } : {}), - transportContext: configContext, + transportContext: { + ...configContext, + logger: LegacyLogger.from(configContext.log), + }, transportEntryMap, getSubgraphSchema() { return unifiedGraph; @@ -735,7 +738,10 @@ export function createGatewayRuntime< transports: config.transports, transportEntryAdditions: config.transportEntries, pollingInterval: config.pollingInterval, - transportContext: configContext, + transportContext: { + ...configContext, + logger: LegacyLogger.from(configContext.log), + }, onDelegateHooks, onSubgraphExecuteHooks, onDelegationPlanHooks, diff --git a/packages/runtime/src/getProxyExecutor.ts b/packages/runtime/src/getProxyExecutor.ts index ec219dbf4..acbc52fad 100644 --- a/packages/runtime/src/getProxyExecutor.ts +++ b/packages/runtime/src/getProxyExecutor.ts @@ -1,3 +1,4 @@ +import { LegacyLogger } from '@graphql-hive/logger'; import type { Instrumentation, OnSubgraphExecuteHook, @@ -42,7 +43,10 @@ export function getProxyExecutor>({ return fakeTransportEntryMap[subgraphNameProp]; }, }), - transportContext: configContext, + transportContext: { + ...configContext, + logger: LegacyLogger.from(configContext.log), + }, getSubgraphSchema: getSchema, transportExecutorStack, transports: config.transports, diff --git a/packages/runtime/src/plugins/usePropagateHeaders.ts b/packages/runtime/src/plugins/usePropagateHeaders.ts index 1e0711401..fae492ab2 100644 --- a/packages/runtime/src/plugins/usePropagateHeaders.ts +++ b/packages/runtime/src/plugins/usePropagateHeaders.ts @@ -33,7 +33,10 @@ export function usePropagateHeaders>( const resHeadersByRequest = new WeakMap>(); return { onFetch({ executionRequest, context, options, setOptions }) { - const request = context?.request || executionRequest?.context?.request; + const request = + 'request' in context + ? context?.request || executionRequest?.context?.request + : undefined; if (request) { const subgraphName = (executionRequest && subgraphNameByExecutionRequest.get(executionRequest))!; diff --git a/packages/runtime/src/plugins/useRequestId.ts b/packages/runtime/src/plugins/useRequestId.ts index ee62dc9b2..95d4f36e0 100644 --- a/packages/runtime/src/plugins/useRequestId.ts +++ b/packages/runtime/src/plugins/useRequestId.ts @@ -1,3 +1,4 @@ +import { LegacyLogger } from '@graphql-hive/logger'; import { loggerForRequest, requestIdByRequest, @@ -52,20 +53,22 @@ export function useRequestId>( requestIdByRequest.set(request, requestId); }, onContextBuilding({ context, extendContext }) { + const log = loggerForRequest(context.log, context.request, () => { + throw new Error( + "Request ID must've already been created but is not found", + ); + // because we are using the logger's requestIdByRequest map + }); extendContext( // @ts-expect-error TODO: typescript is acting up here { - log: loggerForRequest(context.log, context.request, () => { - throw new Error( - "Request ID must've already been created but is not found", - ); - // because we are using the logger's requestIdByRequest map - }), + log, + logger: LegacyLogger.from(log), }, ); }, onFetch({ context, options, setOptions }) { - if (context?.request) { + if ('request' in context) { const requestId = requestIdByRequest.get(context.request); if (requestId) { setOptions({ diff --git a/packages/runtime/src/plugins/useUpstreamCancel.ts b/packages/runtime/src/plugins/useUpstreamCancel.ts index a744a2a7e..556ffe15e 100644 --- a/packages/runtime/src/plugins/useUpstreamCancel.ts +++ b/packages/runtime/src/plugins/useUpstreamCancel.ts @@ -6,7 +6,7 @@ export function useUpstreamCancel(): GatewayPlugin { return { onFetch({ context, options, executionRequest, info }) { const signals: AbortSignal[] = []; - if (context?.request?.signal) { + if ('request' in context) { signals.push(context.request.signal); } const execRequestSignal = diff --git a/packages/runtime/src/types.ts b/packages/runtime/src/types.ts index c1d00bcf7..75d110ea3 100644 --- a/packages/runtime/src/types.ts +++ b/packages/runtime/src/types.ts @@ -12,6 +12,7 @@ import type { HMACUpstreamSignatureOptions } from '@graphql-mesh/hmac-upstream-s import type { ResponseCacheConfig } from '@graphql-mesh/plugin-response-cache'; import type { KeyValueCache, + Logger as LegacyLogger, MeshFetch, MeshFetchRequestInit, } from '@graphql-mesh/types'; @@ -126,6 +127,8 @@ export interface OnFetchHookPayload { * The logger will be available in all cases. */ context: (GatewayContext & TContext) | { log: Logger }; + /** @deprecated Please use `log` from the {@link context} instead. */ + logger: LegacyLogger; info: GraphQLResolveInfo; fetchFn: MeshFetch; setFetchFn: (fetchFn: MeshFetch) => void; diff --git a/packages/runtime/src/wrapFetchWithHooks.ts b/packages/runtime/src/wrapFetchWithHooks.ts index c1f1dc0f8..328f8e2e7 100644 --- a/packages/runtime/src/wrapFetchWithHooks.ts +++ b/packages/runtime/src/wrapFetchWithHooks.ts @@ -1,5 +1,5 @@ import { getInstrumented } from '@envelop/instrumentation'; -import type { Logger } from '@graphql-hive/logger'; +import { LegacyLogger, type Logger } from '@graphql-hive/logger'; import type { MeshFetch } from '@graphql-mesh/types'; import type { ExecutionRequest, MaybePromise } from '@graphql-tools/utils'; import { handleMaybePromise, iterateAsync } from '@whatwg-node/promise-helpers'; @@ -41,6 +41,7 @@ export function wrapFetchWithHooks( options = newOptions; }, context: { log, ...context }, + logger: LegacyLogger.from(log), // @ts-expect-error TODO: why? info, get executionRequest() { diff --git a/packages/runtime/tests/graphos.test.ts b/packages/runtime/tests/graphos.test.ts index 8dc653ace..2ed5143b3 100644 --- a/packages/runtime/tests/graphos.test.ts +++ b/packages/runtime/tests/graphos.test.ts @@ -3,7 +3,8 @@ import { type GatewayConfigContext, type GatewayGraphOSManagedFederationOptions, } from '@graphql-hive/gateway-runtime'; -import { Logger } from '@graphql-hive/logger'; +import { LegacyLogger, Logger } from '@graphql-hive/logger'; +import { TransportContext } from '@graphql-mesh/transport-common'; import { Response } from '@whatwg-node/fetch'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createGraphOSFetcher } from '../src/fetchers/graphos'; @@ -20,9 +21,7 @@ describe('GraphOS', () => { it('should fetch the supergraph SDL', async () => { const { unifiedGraphFetcher } = createTestFetcher({ fetch: mockSDL }); - const result = Promise.resolve().then(() => - unifiedGraphFetcher({ log: new Logger({ level: false }) }), - ); + const result = Promise.resolve().then(() => unifiedGraphFetcher()); await advanceTimersByTimeAsync(1_000); expect(await result).toBe(supergraphSdl); }); @@ -39,9 +38,7 @@ describe('GraphOS', () => { }, }); - const result = Promise.resolve().then(() => - unifiedGraphFetcher({ log: new Logger({ level: false }) }), - ); + const result = Promise.resolve().then(() => unifiedGraphFetcher()); for (let i = 0; i < 3; i++) { await advanceTimersByTimeAsync(1_000); } @@ -56,7 +53,7 @@ describe('GraphOS', () => { ); const result = Promise.resolve() - .then(() => unifiedGraphFetcher({ log: new Logger({ level: false }) })) + .then(() => unifiedGraphFetcher()) .catch((err) => err); for (let i = 0; i < 3; i++) { await advanceTimersByTimeAsync(1_000); @@ -72,7 +69,7 @@ describe('GraphOS', () => { ); const result = Promise.resolve() - .then(() => unifiedGraphFetcher({ log: new Logger({ level: false }) })) + .then(() => unifiedGraphFetcher()) .catch(() => {}); await advanceTimersByTimeAsync(25); expect(mockFetchError).toHaveBeenCalledTimes(1); @@ -88,16 +85,12 @@ describe('GraphOS', () => { it('should respect min-delay between polls', async () => { const { unifiedGraphFetcher } = createTestFetcher({ fetch: mockSDL }); - Promise.resolve().then(() => - unifiedGraphFetcher({ log: new Logger({ level: false }) }), - ); + Promise.resolve().then(() => unifiedGraphFetcher()); await advanceTimersByTimeAsync(25); expect(mockSDL).toHaveBeenCalledTimes(1); await advanceTimersByTimeAsync(20); expect(mockSDL).toHaveBeenCalledTimes(1); - Promise.resolve().then(() => - unifiedGraphFetcher({ log: new Logger({ level: false }) }), - ); + Promise.resolve().then(() => unifiedGraphFetcher()); await advanceTimersByTimeAsync(50); expect(mockSDL).toHaveBeenCalledTimes(1); await advanceTimersByTimeAsync(50); @@ -115,27 +108,19 @@ describe('GraphOS', () => { return mockSDL(); }, }); - const result1 = Promise.resolve().then(() => - unifiedGraphFetcher({ log: new Logger({ level: false }) }), - ); + const result1 = Promise.resolve().then(() => unifiedGraphFetcher()); await advanceTimersByTimeAsync(1_000); - const result2 = Promise.resolve().then(() => - unifiedGraphFetcher({ log: new Logger({ level: false }) }), - ); + const result2 = Promise.resolve().then(() => unifiedGraphFetcher()); await advanceTimersByTimeAsync(1_000); expect(await result1).toBe(await result2); }, 30_000); it('should not wait if min delay is superior to polling interval', async () => { const { unifiedGraphFetcher } = createTestFetcher({ fetch: mockSDL }); - const result = Promise.resolve().then(() => - unifiedGraphFetcher({ log: new Logger({ level: false }) }), - ); + const result = Promise.resolve().then(() => unifiedGraphFetcher()); await advanceTimersByTimeAsync(1_000); await result; - const result2 = Promise.resolve().then(() => - unifiedGraphFetcher({ log: new Logger({ level: false }) }), - ); + const result2 = Promise.resolve().then(() => unifiedGraphFetcher()); await advanceTimersByTimeAsync(1_000); expect(await result).toBe(await result2); }); @@ -162,13 +147,9 @@ describe('GraphOS', () => { }, }); - const result = Promise.resolve().then(() => - unifiedGraphFetcher({ log: new Logger({ level: false }) }), - ); + const result = Promise.resolve().then(() => unifiedGraphFetcher()); await advanceTimersByTimeAsync(1_000); - const result2 = Promise.resolve().then(() => - unifiedGraphFetcher({ log: new Logger({ level: false }) }), - ); + const result2 = Promise.resolve().then(() => unifiedGraphFetcher()); await advanceTimersByTimeAsync(1_000); expect(await result).toBe(await result2); }); @@ -181,9 +162,10 @@ function createTestFetcher( }, opts?: Partial, ) { - return createGraphOSFetcher({ + const log = new Logger({ level: process.env['DEBUG'] ? 'debug' : false }); + const fetcher = createGraphOSFetcher({ configContext: { - log: new Logger({ level: process.env['DEBUG'] ? 'debug' : false }), + log, cwd: process.cwd(), ...configContext, }, @@ -195,6 +177,15 @@ function createTestFetcher( }, pollingInterval: 0.000000001, }); + return { + unifiedGraphFetcher: (transportContext: Partial = {}) => { + return fetcher.unifiedGraphFetcher({ + log, + logger: LegacyLogger.from(log), + ...transportContext, + }); + }, + }; } let supergraphSdl = 'TEST SDL'; diff --git a/packages/runtime/tests/wrapFetchWithHooks.test.ts b/packages/runtime/tests/wrapFetchWithHooks.test.ts index bae527058..145b5a880 100644 --- a/packages/runtime/tests/wrapFetchWithHooks.test.ts +++ b/packages/runtime/tests/wrapFetchWithHooks.test.ts @@ -1,3 +1,4 @@ +import { Logger } from '@graphql-hive/logger'; import { createServerAdapter, Response } from '@whatwg-node/server'; import type { GraphQLResolveInfo } from 'graphql'; import { expect, it } from 'vitest'; @@ -26,6 +27,7 @@ it('should wrap fetch instrumentation', async () => { ); }, ], + new Logger({ level: false }), () => fetchInstrumentation, ); const executionRequest = {}; diff --git a/packages/transports/common/src/types.ts b/packages/transports/common/src/types.ts index 506ac1016..c304d7179 100644 --- a/packages/transports/common/src/types.ts +++ b/packages/transports/common/src/types.ts @@ -1,6 +1,10 @@ import type { Logger } from '@graphql-hive/logger'; import { HivePubSub } from '@graphql-hive/pubsub'; -import type { KeyValueCache, MeshFetch } from '@graphql-mesh/types'; +import type { + KeyValueCache, + Logger as LegacyLogger, + MeshFetch, +} from '@graphql-mesh/types'; import type { Executor, MaybePromise } from '@graphql-tools/utils'; import type { GraphQLError, GraphQLSchema } from 'graphql'; @@ -22,6 +26,8 @@ export interface TransportEntry< export interface TransportContext { log: Logger; + /** @deprecated Please migrate to using the {@link log}. */ + logger: LegacyLogger; /** The fetch API to use. */ fetch?: MeshFetch; /** Will be empty when run on serverless. */ From 6dc001162d44262fb33e7c4cf79e1eb2e8950780 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Wed, 16 Apr 2025 20:08:19 +0200 Subject: [PATCH 064/157] make sure meshlogger fits types of legacylogger --- packages/logger/tests/LegacyLogger.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/logger/tests/LegacyLogger.test.ts b/packages/logger/tests/LegacyLogger.test.ts index b00e4f805..d64368c6a 100644 --- a/packages/logger/tests/LegacyLogger.test.ts +++ b/packages/logger/tests/LegacyLogger.test.ts @@ -1,8 +1,12 @@ import { LegacyLogger } from '@graphql-hive/logger'; +import { Logger as MeshLogger } from '@graphql-mesh/types'; import { expect, it } from 'vitest'; import { Logger, LoggerOptions } from '../src/Logger'; import { MemoryLogWriter } from '../src/writers'; +// a type test making sure the LegacyLogger is compatible with the MeshLogger +export const _: MeshLogger = new LegacyLogger(null as any); + function createTLogger(opts?: Partial) { const writer = new MemoryLogWriter(); return [ From ee40f497f7586bbfc35a250fd0c2899181d294df Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Wed, 16 Apr 2025 20:51:28 +0200 Subject: [PATCH 065/157] retryonschemareload might not have a request (websockets) --- packages/runtime/src/createGatewayRuntime.ts | 2 +- .../src/plugins/useRetryOnSchemaReload.ts | 31 +++++++++++++------ 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/packages/runtime/src/createGatewayRuntime.ts b/packages/runtime/src/createGatewayRuntime.ts index 803011b7f..ffbe567c0 100644 --- a/packages/runtime/src/createGatewayRuntime.ts +++ b/packages/runtime/src/createGatewayRuntime.ts @@ -1027,7 +1027,7 @@ export function createGatewayRuntime< readinessCheckPlugin, registryPlugin, persistedDocumentsPlugin, - useRetryOnSchemaReload(), + useRetryOnSchemaReload({ log }), ]; if (config.subgraphErrors !== false) { diff --git a/packages/runtime/src/plugins/useRetryOnSchemaReload.ts b/packages/runtime/src/plugins/useRetryOnSchemaReload.ts index f826a98ff..c6e70fe19 100644 --- a/packages/runtime/src/plugins/useRetryOnSchemaReload.ts +++ b/packages/runtime/src/plugins/useRetryOnSchemaReload.ts @@ -11,9 +11,11 @@ import type { GatewayPlugin } from '../types'; type ExecHandler = () => MaybePromise>; -export function useRetryOnSchemaReload< - TContext extends Record, ->(): GatewayPlugin { +export function useRetryOnSchemaReload>({ + log: rootLog, +}: { + log: Logger; +}): GatewayPlugin { const execHandlerByContext = new WeakMap<{}, ExecHandler>(); function handleOnExecute(args: ExecutionArgs) { if (args.contextValue) { @@ -34,17 +36,20 @@ export function useRetryOnSchemaReload< context: {}; result?: ExecutionResult; setResult: (result: MaybeAsyncIterable) => void; - request: Request; + // request wont be available over websockets + request: Request | undefined; }) { const execHandler = execHandlerByContext.get(context); if ( execHandler && result?.errors?.some((e) => e.extensions?.['code'] === 'SCHEMA_RELOAD') ) { - const log = loggerForRequest( - logForRequest.get(request)!, // must exist at this point - request, - ); + const log = request + ? loggerForRequest( + logForRequest.get(request)!, // must exist at this point + request, + ) + : rootLog; log.info( 'The operation has been aborted after the supergraph schema reloaded, retrying the operation...', ); @@ -67,12 +72,18 @@ export function useRetryOnSchemaReload< }, onExecute({ args, context }) { // we set the logger here because it most likely contains important attributes (like the request-id) - logForRequest.set(context.request, context.log); + if (context.request) { + // the request wont be available over websockets + logForRequest.set(context.request, context.log); + } handleOnExecute(args); }, onSubscribe({ args, context }) { // we set the logger here because it most likely contains important attributes (like the request-id) - logForRequest.set(context.request, context.log); + if (context.request) { + // the request wont be available over websockets + logForRequest.set(context.request, context.log); + } handleOnExecute(args); }, onExecutionResult({ request, context, result, setResult }) { From c530e0ab72be23ffae575c1c3e45aa1aaaae9e43 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Wed, 16 Apr 2025 20:52:04 +0200 Subject: [PATCH 066/157] request is not there for websokets --- packages/runtime/src/plugins/useRequestId.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/runtime/src/plugins/useRequestId.ts b/packages/runtime/src/plugins/useRequestId.ts index 95d4f36e0..017dd2dc5 100644 --- a/packages/runtime/src/plugins/useRequestId.ts +++ b/packages/runtime/src/plugins/useRequestId.ts @@ -53,12 +53,8 @@ export function useRequestId>( requestIdByRequest.set(request, requestId); }, onContextBuilding({ context, extendContext }) { - const log = loggerForRequest(context.log, context.request, () => { - throw new Error( - "Request ID must've already been created but is not found", - ); - // because we are using the logger's requestIdByRequest map - }); + // the request ID wont always be available because there's no request in websockets + const log = loggerForRequest(context.log, context.request); extendContext( // @ts-expect-error TODO: typescript is acting up here { From fedfa500546f5652c1e2afd90beff4fb7dc85dcf Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Wed, 16 Apr 2025 21:16:42 +0200 Subject: [PATCH 067/157] skip error serial on bun --- packages/logger/tests/Logger.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/logger/tests/Logger.test.ts b/packages/logger/tests/Logger.test.ts index 96e8a9e40..23835c283 100644 --- a/packages/logger/tests/Logger.test.ts +++ b/packages/logger/tests/Logger.test.ts @@ -10,7 +10,10 @@ function createTLogger(opts?: Partial) { ] as const; } -it('should write logs with levels, message and attributes', () => { +it.skipIf( + // skip on bun because bun serialises errors differently from node (failing the snapshot) + globalThis.Bun, +)('should write logs with levels, message and attributes', () => { const [log, writter] = createTLogger(); const err = new Error('Woah!'); @@ -281,7 +284,10 @@ it('should format string', () => { `); }); -it('should write logs with unexpected attributes', () => { +it.skipIf( + // skip on bun because bun serialises errors differently from node (failing the snapshot) + globalThis.Bun, +)('should write logs with unexpected attributes', () => { const [log, writer] = createTLogger(); const err = new Error('Woah!'); From df4b1d4e84a463bc3d656a187e1b728c676ed4d0 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Wed, 16 Apr 2025 21:19:51 +0200 Subject: [PATCH 068/157] transform class private methods for jest --- babel.config.cjs | 1 + package.json | 1 + yarn.lock | 1 + 3 files changed, 3 insertions(+) diff --git a/babel.config.cjs b/babel.config.cjs index b5586a0c4..3bb0a78f3 100644 --- a/babel.config.cjs +++ b/babel.config.cjs @@ -11,5 +11,6 @@ module.exports = { ['@babel/plugin-proposal-decorators', { version: '2023-11' }], '@babel/plugin-transform-class-properties', '@babel/plugin-proposal-explicit-resource-management', + '@babel/plugin-transform-private-methods', ], }; diff --git a/package.json b/package.json index d888a29e1..182ddd3e6 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@babel/plugin-proposal-explicit-resource-management": "7.27.4", "@babel/plugin-transform-class-properties": "7.27.1", "@babel/plugin-transform-class-static-block": "7.27.1", + "@babel/plugin-transform-private-methods": "^7.27.1", "@babel/preset-env": "7.27.2", "@babel/preset-typescript": "7.27.1", "@changesets/changelog-github": "^0.5.0", diff --git a/yarn.lock b/yarn.lock index 35d5c4abf..78b414ca1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13645,6 +13645,7 @@ __metadata: "@babel/plugin-proposal-explicit-resource-management": "npm:7.27.4" "@babel/plugin-transform-class-properties": "npm:7.27.1" "@babel/plugin-transform-class-static-block": "npm:7.27.1" + "@babel/plugin-transform-private-methods": "npm:^7.27.1" "@babel/preset-env": "npm:7.27.2" "@babel/preset-typescript": "npm:7.27.1" "@changesets/changelog-github": "npm:^0.5.0" From b90c893096f9d4bce9954df76cf28dc2568c28a5 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 17 Apr 2025 18:17:40 +0200 Subject: [PATCH 069/157] correct snap for bun --- e2e/config-syntax-error/config-syntax-error.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/config-syntax-error/config-syntax-error.e2e.ts b/e2e/config-syntax-error/config-syntax-error.e2e.ts index 5d5f87795..433d0cb74 100644 --- a/e2e/config-syntax-error/config-syntax-error.e2e.ts +++ b/e2e/config-syntax-error/config-syntax-error.e2e.ts @@ -31,7 +31,7 @@ it.skipIf( }), ).rejects.toThrowError( gatewayRunner === 'bun' || gatewayRunner === 'bun-docker' - ? /error: Expected \\"{\\" but found \\"hello\\"(.|\n)*\/custom-resolvers.ts:8:11/ + ? /Expected \\"{\\" but found \\"hello\\"(.|\n)*\/custom-resolvers.ts/ : /Error transforming .*(\/|\\)custom-resolvers.ts: Unexpected token, expected \\"{\\" \(8:11\)/, ); }, From 02e2f8e9bd577ffe87aec13846be1e9308db3c31 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 17 Apr 2025 18:39:58 +0200 Subject: [PATCH 070/157] skip instead of todo for leaktests --- packages/plugins/aws-sigv4/tests/aws-sigv4-incoming.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/plugins/aws-sigv4/tests/aws-sigv4-incoming.test.ts b/packages/plugins/aws-sigv4/tests/aws-sigv4-incoming.test.ts index f2c956409..25498f0db 100644 --- a/packages/plugins/aws-sigv4/tests/aws-sigv4-incoming.test.ts +++ b/packages/plugins/aws-sigv4/tests/aws-sigv4-incoming.test.ts @@ -10,7 +10,7 @@ import { createYoga } from 'graphql-yoga'; import { describe, expect, it } from 'vitest'; import { useAWSSigv4 } from '../src'; -describe.todo('AWS Sigv4 Incoming requests', () => { +describe.skip('AWS Sigv4 Incoming requests', () => { const subgraphSchema = buildSubgraphSchema({ typeDefs: parse(/* GraphQL */ ` type Query { From 6c38081e28ab8af7aff9338745ac5b71c0fcf169 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 17 Apr 2025 20:03:45 +0200 Subject: [PATCH 071/157] jwt use log and enable aws --- .../plugins/aws-sigv4/tests/aws-sigv4-incoming.test.ts | 2 +- packages/plugins/jwt-auth/src/index.ts | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/plugins/aws-sigv4/tests/aws-sigv4-incoming.test.ts b/packages/plugins/aws-sigv4/tests/aws-sigv4-incoming.test.ts index 25498f0db..26d452c60 100644 --- a/packages/plugins/aws-sigv4/tests/aws-sigv4-incoming.test.ts +++ b/packages/plugins/aws-sigv4/tests/aws-sigv4-incoming.test.ts @@ -10,7 +10,7 @@ import { createYoga } from 'graphql-yoga'; import { describe, expect, it } from 'vitest'; import { useAWSSigv4 } from '../src'; -describe.skip('AWS Sigv4 Incoming requests', () => { +describe('AWS Sigv4 Incoming requests', () => { const subgraphSchema = buildSubgraphSchema({ typeDefs: parse(/* GraphQL */ ` type Query { diff --git a/packages/plugins/jwt-auth/src/index.ts b/packages/plugins/jwt-auth/src/index.ts index 001373555..f0aad76e8 100644 --- a/packages/plugins/jwt-auth/src/index.ts +++ b/packages/plugins/jwt-auth/src/index.ts @@ -76,7 +76,7 @@ export function useJWT( executionRequest, subgraphName, setExecutionRequest, - logger, + log, }) { if (shouldForward && executionRequest.context?.jwt) { const jwtData: Partial = { @@ -86,9 +86,10 @@ export function useJWT( token: forwardToken ? executionRequest.context.jwt.token : undefined, }; - logger?.debug( - `Forwarding JWT payload to subgraph ${subgraphName}, payload: `, - jwtData.payload, + log.debug( + { payload: jwtData.payload }, + 'Forwarding JWT payload to subgraph %s', + subgraphName, ); setExecutionRequest({ From ed802f85ab5c8a63583fef104736aeb229db9e79 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 17 Apr 2025 20:24:57 +0200 Subject: [PATCH 072/157] logger for request in hmac --- packages/plugins/hmac-upstream-signature/src/index.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/plugins/hmac-upstream-signature/src/index.ts b/packages/plugins/hmac-upstream-signature/src/index.ts index fcd9b0be6..bd11f57c9 100644 --- a/packages/plugins/hmac-upstream-signature/src/index.ts +++ b/packages/plugins/hmac-upstream-signature/src/index.ts @@ -1,5 +1,6 @@ import type { GatewayPlugin } from '@graphql-hive/gateway-runtime'; import type { Logger } from '@graphql-hive/logger'; +import { loggerForRequest } from '@graphql-hive/logger/request'; import type { OnSubgraphExecutePayload } from '@graphql-mesh/fusion-runtime'; import { serializeExecutionRequest } from '@graphql-tools/executor-common'; import type { ExecutionRequest } from '@graphql-tools/utils'; @@ -165,7 +166,8 @@ export function useHmacSignatureValidation( const paramsSerializer = options.serializeParams || defaultParamsSerializer; return { - onParams({ params, fetchAPI }) { + onParams({ params, fetchAPI, request }) { + const log = loggerForRequest(options.log, request); textEncoder ||= new fetchAPI.TextEncoder(); const extension = params.extensions?.[extensionName]; @@ -189,7 +191,7 @@ export function useHmacSignatureValidation( c.charCodeAt(0), ); const serializedParams = paramsSerializer(params); - options.log.debug( + log.debug( 'HMAC signature will be calculate based on serialized params %s', serializedParams, ); @@ -204,10 +206,9 @@ export function useHmacSignatureValidation( ), (result) => { if (!result) { - options.log.error( + log.error( 'HMAC signature does not match the body content. short circuit request.', ); - throw new Error( `Invalid HMAC signature: extension ${extensionName} does not match the body content.`, ); From 5c34afed1ead2f7ec2b4d988479a28118d2323ac Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 17 Apr 2025 20:25:00 +0200 Subject: [PATCH 073/157] changeset for logger --- .changeset/tough-elephants-shop.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/tough-elephants-shop.md diff --git a/.changeset/tough-elephants-shop.md b/.changeset/tough-elephants-shop.md new file mode 100644 index 000000000..8f34ed113 --- /dev/null +++ b/.changeset/tough-elephants-shop.md @@ -0,0 +1,5 @@ +--- +'@graphql-hive/logger': major +--- + +Introducing Hive Logger From d0eab94d96e78a7cbd98b8965096fa1eca9634ba Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 17 Apr 2025 20:45:30 +0200 Subject: [PATCH 074/157] hmac needs logger for services --- e2e/hmac-auth-https/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/e2e/hmac-auth-https/package.json b/e2e/hmac-auth-https/package.json index 66efaaf52..5a0c5d49d 100644 --- a/e2e/hmac-auth-https/package.json +++ b/e2e/hmac-auth-https/package.json @@ -9,6 +9,7 @@ "@apollo/server": "^4.12.2", "@apollo/subgraph": "^2.11.0", "@graphql-hive/gateway": "workspace:^", + "@graphql-hive/logger": "workspace:^", "@graphql-mesh/compose-cli": "^1.4.1", "@graphql-mesh/hmac-upstream-signature": "workspace:^", "@graphql-mesh/plugin-jwt-auth": "workspace:^", From f78c5c7d8589922739d0e065aeba8cb0986ce960 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 17 Apr 2025 20:54:16 +0200 Subject: [PATCH 075/157] disallow deep function unwraps only --- packages/logger/src/utils.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/logger/src/utils.ts b/packages/logger/src/utils.ts index c45727875..53a6d1890 100644 --- a/packages/logger/src/utils.ts +++ b/packages/logger/src/utils.ts @@ -56,25 +56,25 @@ export function isPromise(val: unknown): val is Promise { /** Recursivelly unwrapps the lazy attributes and parses instances of classes. */ export function parseAttrs( attrs: MaybeLazy, - depth = 0, + functionUnwrapDepth = 0, ): Attributes { - if (depth > 10) { + if (functionUnwrapDepth > 3) { throw new Error('Too much recursion while unwrapping function attributes'); } if (typeof attrs === 'function') { - return parseAttrs(attrs(), depth + 1); + return parseAttrs(attrs(), functionUnwrapDepth + 1); } if (Array.isArray(attrs)) { - return attrs.map((val) => unwrapAttrVal(val, depth + 1)); + return attrs.map((val) => unwrapAttrVal(val, functionUnwrapDepth)); } if (Object.prototype.toString.call(attrs) === '[object Object]') { const unwrapped: Attributes = {}; for (const key of Object.keys(attrs)) { const val = attrs[key as keyof typeof attrs]; - unwrapped[key] = unwrapAttrVal(val, depth + 1); + unwrapped[key] = unwrapAttrVal(val, functionUnwrapDepth); } return unwrapped; } @@ -84,9 +84,9 @@ export function parseAttrs( function unwrapAttrVal( attr: MaybeLazy, - depth = 0, + functionUnwrapDepth = 0, ): AttributeValue { - if (depth > 10) { + if (functionUnwrapDepth > 3) { throw new Error( 'Too much recursion while unwrapping function attribute values', ); @@ -101,11 +101,11 @@ function unwrapAttrVal( } if (typeof attr === 'function') { - return unwrapAttrVal(attr(), depth + 1); + return unwrapAttrVal(attr(), functionUnwrapDepth + 1); } if (Array.isArray(attr)) { - return attr.map((val) => unwrapAttrVal(val, depth + 1)); + return attr.map((val) => unwrapAttrVal(val, functionUnwrapDepth)); } // plain object (not an instance of anything) @@ -114,7 +114,7 @@ function unwrapAttrVal( const unwrapped: { [key: string | number]: AttributeValue } = {}; for (const key of Object.keys(attr)) { const val = attr[key as keyof typeof attr]; - unwrapped[key] = unwrapAttrVal(val, depth + 1); + unwrapped[key] = unwrapAttrVal(val, functionUnwrapDepth); } return unwrapped; } From df9d06dd346fbaf1940aea30d93101f3dfe54814 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 17 Apr 2025 20:57:52 +0200 Subject: [PATCH 076/157] test edgecase --- packages/logger/tests/Logger.test.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/logger/tests/Logger.test.ts b/packages/logger/tests/Logger.test.ts index 23835c283..2286255c7 100644 --- a/packages/logger/tests/Logger.test.ts +++ b/packages/logger/tests/Logger.test.ts @@ -259,6 +259,27 @@ it.todo('should log to async writers'); it.todo('should wait for async writers on flush'); +it('should log array attributes with object child attributes', () => { + let [log, writer] = createTLogger(); + + log = log.child({ hello: 'world' }); + log.info(['hello', 'world']); + + // TODO: should it be logged like this? maybe place the child attrs in the array as first child? + expect(writer.logs).toMatchInlineSnapshot(` + [ + { + "attrs": { + "0": "hello", + "1": "world", + "hello": "world", + }, + "level": "info", + }, + ] + `); +}); + it('should format string', () => { const [log, writer] = createTLogger(); From a45e678dacb9dc0f628f6ed96339e08c38c55c97 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Fri, 18 Apr 2025 15:57:55 +0200 Subject: [PATCH 077/157] shallow merge attrs --- packages/logger/src/Logger.ts | 10 +++-- packages/logger/src/utils.ts | 23 ++++++++++++ packages/logger/tests/Logger.test.ts | 56 +++++++++++++++++++++++++--- 3 files changed, 80 insertions(+), 9 deletions(-) diff --git a/packages/logger/src/Logger.ts b/packages/logger/src/Logger.ts index 0553f88b3..02e7a9702 100644 --- a/packages/logger/src/Logger.ts +++ b/packages/logger/src/Logger.ts @@ -6,6 +6,7 @@ import { logLevel, MaybeLazy, parseAttrs, + shallowMergeAttributes, shouldLog, truthyEnv, } from './utils'; @@ -138,7 +139,7 @@ export class Logger implements LogWriter { return new Logger({ level: () => this.level, // inherits the parent level (yet can be changed on child only when using setLevel) prefix: (this.#prefix || '') + (prefix || '') || undefined, - attrs: { ...this.#attrs, ...prefixOrAttrs }, + attrs: shallowMergeAttributes(this.#attrs, prefixOrAttrs), writers: this.#writers, }); } @@ -179,8 +180,11 @@ export class Logger implements LogWriter { msg = `${this.#prefix}${msg || ''}`.trim(); // we trim everything because maybe the "msg" is empty } - attrs = attrs ? parseAttrs(attrs) : attrs; - attrs = this.#attrs ? { ...parseAttrs(this.#attrs), ...attrs } : attrs; + attrs = shallowMergeAttributes( + this.#attrs ? parseAttrs(this.#attrs) : undefined, + attrs ? parseAttrs(attrs) : undefined, + ); + msg = msg ? format(msg, rest) : msg; this.write(level, attrs, msg); diff --git a/packages/logger/src/utils.ts b/packages/logger/src/utils.ts index 53a6d1890..e7ae5a249 100644 --- a/packages/logger/src/utils.ts +++ b/packages/logger/src/utils.ts @@ -168,3 +168,26 @@ export function truthyEnv(key: string): boolean { getEnv(key)?.toLowerCase() || '', ); } + +export function shallowMergeAttributes( + target: Attributes | undefined, + source: Attributes | undefined, +): Attributes | undefined { + switch (true) { + case Array.isArray(source) && Array.isArray(target): + // both are arrays + return [...target, ...source]; + case Array.isArray(source): + // only "source" is an array + return target ? [target, ...source] : source; + case Array.isArray(target): + // only "target" is an array + return source ? [...target, source] : target; + case !!(target || source): + // neither are arrays, but at least one is an object + return { ...target, ...source }; + default: + // neither are provided + return undefined; + } +} diff --git a/packages/logger/tests/Logger.test.ts b/packages/logger/tests/Logger.test.ts index 2286255c7..94e7c3140 100644 --- a/packages/logger/tests/Logger.test.ts +++ b/packages/logger/tests/Logger.test.ts @@ -265,15 +265,59 @@ it('should log array attributes with object child attributes', () => { log = log.child({ hello: 'world' }); log.info(['hello', 'world']); - // TODO: should it be logged like this? maybe place the child attrs in the array as first child? expect(writer.logs).toMatchInlineSnapshot(` [ { - "attrs": { - "0": "hello", - "1": "world", - "hello": "world", - }, + "attrs": [ + { + "hello": "world", + }, + "hello", + "world", + ], + "level": "info", + }, + ] + `); +}); + +it('should log array child attributes with object attributes', () => { + let [log, writer] = createTLogger(); + + log = log.child(['hello', 'world']); + log.info({ hello: 'world' }); + + expect(writer.logs).toMatchInlineSnapshot(` + [ + { + "attrs": [ + "hello", + "world", + { + "hello": "world", + }, + ], + "level": "info", + }, + ] + `); +}); + +it('should log array child attributes with array attributes', () => { + let [log, writer] = createTLogger(); + + log = log.child(['hello', 'world']); + log.info(['more', 'life']); + + expect(writer.logs).toMatchInlineSnapshot(` + [ + { + "attrs": [ + "hello", + "world", + "more", + "life", + ], "level": "info", }, ] From 51ae74461fd44643a7f073d047054bec8739e4d5 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Fri, 18 Apr 2025 16:14:53 +0200 Subject: [PATCH 078/157] no unwrap of attr values --- packages/logger/src/utils.ts | 21 +++------- packages/logger/tests/Logger.test.ts | 62 ++++++++++++++-------------- 2 files changed, 36 insertions(+), 47 deletions(-) diff --git a/packages/logger/src/utils.ts b/packages/logger/src/utils.ts index e7ae5a249..c52c38303 100644 --- a/packages/logger/src/utils.ts +++ b/packages/logger/src/utils.ts @@ -67,14 +67,14 @@ export function parseAttrs( } if (Array.isArray(attrs)) { - return attrs.map((val) => unwrapAttrVal(val, functionUnwrapDepth)); + return attrs.map((val) => unwrapAttrVal(val)); } if (Object.prototype.toString.call(attrs) === '[object Object]') { const unwrapped: Attributes = {}; for (const key of Object.keys(attrs)) { const val = attrs[key as keyof typeof attrs]; - unwrapped[key] = unwrapAttrVal(val, functionUnwrapDepth); + unwrapped[key] = unwrapAttrVal(val); } return unwrapped; } @@ -82,16 +82,7 @@ export function parseAttrs( return objectifyClass(attrs); } -function unwrapAttrVal( - attr: MaybeLazy, - functionUnwrapDepth = 0, -): AttributeValue { - if (functionUnwrapDepth > 3) { - throw new Error( - 'Too much recursion while unwrapping function attribute values', - ); - } - +function unwrapAttrVal(attr: AttributeValue): AttributeValue { if (!attr) { return attr; } @@ -101,11 +92,11 @@ function unwrapAttrVal( } if (typeof attr === 'function') { - return unwrapAttrVal(attr(), functionUnwrapDepth + 1); + return `[Function: ${attr.name || '(anonymous)'}]`; } if (Array.isArray(attr)) { - return attr.map((val) => unwrapAttrVal(val, functionUnwrapDepth)); + return attr.map((val) => unwrapAttrVal(val)); } // plain object (not an instance of anything) @@ -114,7 +105,7 @@ function unwrapAttrVal( const unwrapped: { [key: string | number]: AttributeValue } = {}; for (const key of Object.keys(attr)) { const val = attr[key as keyof typeof attr]; - unwrapped[key] = unwrapAttrVal(val, functionUnwrapDepth); + unwrapped[key] = unwrapAttrVal(val); } return unwrapped; } diff --git a/packages/logger/tests/Logger.test.ts b/packages/logger/tests/Logger.test.ts index 94e7c3140..f5a91f26d 100644 --- a/packages/logger/tests/Logger.test.ts +++ b/packages/logger/tests/Logger.test.ts @@ -179,25 +179,14 @@ it('should include attributes and prefix in nested child loggers', () => { `); }); -it('should unwrap lazy attributes', () => { +it('should unwrap lazy attribute values', () => { const [log, writter] = createTLogger(); - log.info( - { - lazy: () => 'lazy', - nested: { - lazy: () => 'nested lazy', - }, - arr: [() => '0', '1'], - }, - 'hello', - ); - log.info( () => ({ every: 'thing', nested: { - lazy: () => 'nested lazy', + lazy: () => 'nested lazy not unwrapped', }, }), 'hello', @@ -207,23 +196,43 @@ it('should unwrap lazy attributes', () => { [ { "attrs": { - "arr": [ - "0", - "1", - ], - "lazy": "lazy", + "every": "thing", "nested": { - "lazy": "nested lazy", + "lazy": "[Function: lazy]", }, }, "level": "info", "msg": "hello", }, + ] + `); +}); + +it('should not unwrap lazy attribute values', () => { + const [log, writter] = createTLogger(); + + log.info( + { + lazy: () => 'lazy', + nested: { + lazy: () => 'nested lazy', + }, + arr: [() => '0', '1'], + }, + 'hello', + ); + + expect(writter.logs).toMatchInlineSnapshot(` + [ { "attrs": { - "every": "thing", + "arr": [ + "[Function: (anonymous)]", + "1", + ], + "lazy": "[Function: lazy]", "nested": { - "lazy": "nested lazy", + "lazy": "[Function: lazy]", }, }, "level": "info", @@ -239,17 +248,6 @@ it('should not unwrap lazy attributes if level is not to be logged', () => { }); const lazy = vi.fn(() => ({ la: 'zy' })); - log.debug( - { - lazy, - nested: { - lazy, - }, - arr: [lazy, '1'], - }, - 'hello', - ); - log.debug(lazy, 'hello'); expect(lazy).not.toHaveBeenCalled(); From ea64b75127d875e100e4432e51f0b85d99ad7249 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Fri, 18 Apr 2025 16:22:15 +0200 Subject: [PATCH 079/157] no logging of lazy attributes returning nothing --- packages/logger/src/Logger.ts | 5 +---- packages/logger/src/utils.ts | 8 ++++++-- packages/logger/tests/Logger.test.ts | 25 +++++++++++++++++++++++++ 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/packages/logger/src/Logger.ts b/packages/logger/src/Logger.ts index 02e7a9702..a1ec629c8 100644 --- a/packages/logger/src/Logger.ts +++ b/packages/logger/src/Logger.ts @@ -180,10 +180,7 @@ export class Logger implements LogWriter { msg = `${this.#prefix}${msg || ''}`.trim(); // we trim everything because maybe the "msg" is empty } - attrs = shallowMergeAttributes( - this.#attrs ? parseAttrs(this.#attrs) : undefined, - attrs ? parseAttrs(attrs) : undefined, - ); + attrs = shallowMergeAttributes(parseAttrs(this.#attrs), parseAttrs(attrs)); msg = msg ? format(msg, rest) : msg; diff --git a/packages/logger/src/utils.ts b/packages/logger/src/utils.ts index c52c38303..4e5e55db3 100644 --- a/packages/logger/src/utils.ts +++ b/packages/logger/src/utils.ts @@ -55,13 +55,17 @@ export function isPromise(val: unknown): val is Promise { /** Recursivelly unwrapps the lazy attributes and parses instances of classes. */ export function parseAttrs( - attrs: MaybeLazy, + attrs: MaybeLazy | undefined, functionUnwrapDepth = 0, -): Attributes { +): Attributes | undefined { if (functionUnwrapDepth > 3) { throw new Error('Too much recursion while unwrapping function attributes'); } + if (!attrs) { + return undefined; + } + if (typeof attrs === 'function') { return parseAttrs(attrs(), functionUnwrapDepth + 1); } diff --git a/packages/logger/tests/Logger.test.ts b/packages/logger/tests/Logger.test.ts index f5a91f26d..9d6db5649 100644 --- a/packages/logger/tests/Logger.test.ts +++ b/packages/logger/tests/Logger.test.ts @@ -208,6 +208,31 @@ it('should unwrap lazy attribute values', () => { `); }); +it('should not log lazy attributes returning nothing', () => { + const [log, writter] = createTLogger(); + + log.info(() => undefined, 'hello'); + log.info(() => null, 'wor'); + log.info(() => void 0, 'ld'); + + expect(writter.logs).toMatchInlineSnapshot(` + [ + { + "level": "info", + "msg": "hello", + }, + { + "level": "info", + "msg": "wor", + }, + { + "level": "info", + "msg": "ld", + }, + ] + `); +}); + it('should not unwrap lazy attribute values', () => { const [log, writter] = createTLogger(); From bf18bb098fcba474fb7bac9066976385abf70c3d Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Fri, 18 Apr 2025 16:26:35 +0200 Subject: [PATCH 080/157] delegation plan is under delegation plan --- packages/runtime/src/plugins/useDelegationPlanDebug.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/runtime/src/plugins/useDelegationPlanDebug.ts b/packages/runtime/src/plugins/useDelegationPlanDebug.ts index 477b84243..e0dffd7ea 100644 --- a/packages/runtime/src/plugins/useDelegationPlanDebug.ts +++ b/packages/runtime/src/plugins/useDelegationPlanDebug.ts @@ -47,8 +47,8 @@ export function useDelegationPlanDebug< }, 'delegation-plan-start'); return ({ delegationPlan }) => { log.debug( - () => - delegationPlan.map((plan) => { + () => ({ + delegationPlan: delegationPlan.map((plan) => { const planObj: Record = {}; for (const [subschema, selectionSet] of plan) { if (subschema.name) { @@ -57,6 +57,7 @@ export function useDelegationPlanDebug< } return planObj; }), + }), 'delegation-plan-done', ); }; From d8a4e84fdfa8d2b2695f1238459c94a2e4c11684 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Fri, 18 Apr 2025 16:43:06 +0200 Subject: [PATCH 081/157] use fastsafestringify for formatting --- packages/logger/src/Logger.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/logger/src/Logger.ts b/packages/logger/src/Logger.ts index a1ec629c8..52135a8f5 100644 --- a/packages/logger/src/Logger.ts +++ b/packages/logger/src/Logger.ts @@ -1,3 +1,4 @@ +import fastSafeStringify from 'fast-safe-stringify'; import format from 'quick-format-unescaped'; import { Attributes, @@ -182,7 +183,7 @@ export class Logger implements LogWriter { attrs = shallowMergeAttributes(parseAttrs(this.#attrs), parseAttrs(attrs)); - msg = msg ? format(msg, rest) : msg; + msg = msg ? format(msg, rest, { stringify: fastSafeStringify }) : msg; this.write(level, attrs, msg); if (truthyEnv('LOG_TRACE_LOGS')) { From c9613484a807a9429a11e6927ae5b1fad8276770 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Fri, 18 Apr 2025 16:43:10 +0200 Subject: [PATCH 082/157] explain deps --- packages/logger/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/logger/package.json b/packages/logger/package.json index f5c29b5c0..0c8b340ca 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -54,5 +54,6 @@ "pkgroll": "2.11.2", "quick-format-unescaped": "^4.0.4" }, - "sideEffects": false + "sideEffects": false, + "dependencies.info": "all of the dependencies are in devDependencies which will bundle them into the package using pkgroll making pkgroll ultimately zero-dep with a smaller footprint because of tree-shaking" } From 1ae6d9f579541e249c916506cb66523328a92d9b Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Fri, 18 Apr 2025 16:44:25 +0200 Subject: [PATCH 083/157] no scoped --- packages/logger/src/Logger.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/logger/src/Logger.ts b/packages/logger/src/Logger.ts index 52135a8f5..0966c4b4f 100644 --- a/packages/logger/src/Logger.ts +++ b/packages/logger/src/Logger.ts @@ -17,8 +17,6 @@ export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error'; // TODO: explain what happens when attribute keys match existing keys from the logger (like "msg") -// TODO: an "id" or "name" of a logger allowing us to create scoped loggers which on their own can be disabled/enabled - export interface LoggerOptions { /** * The minimum log level to log. From 1b97a48409af9ec9d1e8d37a2718ed46cf580e6e Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Fri, 18 Apr 2025 19:33:51 +0200 Subject: [PATCH 084/157] fix lockfile --- yarn.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/yarn.lock b/yarn.lock index 78b414ca1..611bbd079 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3018,6 +3018,7 @@ __metadata: "@apollo/server": "npm:^4.12.2" "@apollo/subgraph": "npm:^2.11.0" "@graphql-hive/gateway": "workspace:^" + "@graphql-hive/logger": "workspace:^" "@graphql-mesh/compose-cli": "npm:^1.4.1" "@graphql-mesh/hmac-upstream-signature": "workspace:^" "@graphql-mesh/plugin-jwt-auth": "workspace:^" From 2f72463c157a29794bc1a1fe35579b63664e6e3c Mon Sep 17 00:00:00 2001 From: theguild-bot Date: Fri, 18 Apr 2025 17:38:39 +0000 Subject: [PATCH 085/157] docs(examples): converted from e2es --- examples/hmac-auth-https/services/users/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/examples/hmac-auth-https/services/users/index.ts b/examples/hmac-auth-https/services/users/index.ts index 22429fd51..c03522fb5 100644 --- a/examples/hmac-auth-https/services/users/index.ts +++ b/examples/hmac-auth-https/services/users/index.ts @@ -2,6 +2,7 @@ import { readFileSync } from 'fs'; import { createServer } from 'https'; import { join } from 'path'; import { buildSubgraphSchema } from '@apollo/subgraph'; +import { Logger } from '@graphql-hive/logger'; import { useHmacSignatureValidation } from '@graphql-mesh/hmac-upstream-signature'; import { JWTExtendContextFields, @@ -19,6 +20,7 @@ const yoga = createYoga({ logging: true, plugins: [ useHmacSignatureValidation({ + log: new Logger({ level: 'debug' }), secret: 'HMAC_SIGNING_SECRET', }), useForwardedJWT({}), From 8c0823a6be5c532637f6d7100e1d40c6830e33c0 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Fri, 18 Apr 2025 19:47:49 +0200 Subject: [PATCH 086/157] flush test and no flush in writers --- packages/logger/src/writers.ts | 12 ++------ packages/logger/tests/Logger.test.ts | 41 ++++++++++++++++++++++++++-- 2 files changed, 40 insertions(+), 13 deletions(-) diff --git a/packages/logger/src/writers.ts b/packages/logger/src/writers.ts index b354fcd5c..77c1bfbd1 100644 --- a/packages/logger/src/writers.ts +++ b/packages/logger/src/writers.ts @@ -6,13 +6,14 @@ export function jsonStringify(val: unknown, pretty?: boolean): string { return fastSafeStringify(val, undefined, pretty ? 2 : undefined); } +// TODO: decide whether logwriters need to have a flush method too or not (the logger will flush any pending writes) + export interface LogWriter { write( level: LogLevel, attrs: Attributes | null | undefined, msg: string | null | undefined, ): void | Promise; - flush(): void | Promise; } export class MemoryLogWriter implements LogWriter { @@ -28,9 +29,6 @@ export class MemoryLogWriter implements LogWriter { ...(attrs ? { attrs } : {}), }); } - flush(): void { - // noop - } } const asciMap = { @@ -75,9 +73,6 @@ export class ConsoleLogWriter implements LogWriter { ].join(' '), ); } - flush() { - // noop - } } export class JSONLogWriter implements LogWriter { @@ -98,7 +93,4 @@ export class JSONLogWriter implements LogWriter { ), ); } - flush() { - // noop - } } diff --git a/packages/logger/tests/Logger.test.ts b/packages/logger/tests/Logger.test.ts index 9d6db5649..f1c5117f2 100644 --- a/packages/logger/tests/Logger.test.ts +++ b/packages/logger/tests/Logger.test.ts @@ -1,6 +1,7 @@ +import { setTimeout } from 'node:timers/promises'; import { expect, it, vi } from 'vitest'; import { Logger, LoggerOptions } from '../src/Logger'; -import { MemoryLogWriter } from '../src/writers'; +import { LogWriter, MemoryLogWriter } from '../src/writers'; function createTLogger(opts?: Partial) { const writer = new MemoryLogWriter(); @@ -278,9 +279,43 @@ it('should not unwrap lazy attributes if level is not to be logged', () => { expect(lazy).not.toHaveBeenCalled(); }); -it.todo('should log to async writers'); +it('should wait for async writers on flush', async () => { + const logs: any[] = []; + const log = new Logger({ + writers: [ + { + async write(level, attrs, msg) { + await setTimeout(10); + logs.push({ level, attrs, msg }); + }, + }, + ], + }); + + log.info('hello'); + log.info('world'); + + // not flushed yet + expect(logs).toMatchInlineSnapshot(`[]`); + + await log.flush(); -it.todo('should wait for async writers on flush'); + // flushed + expect(logs).toMatchInlineSnapshot(` + [ + { + "attrs": undefined, + "level": "info", + "msg": "hello", + }, + { + "attrs": undefined, + "level": "info", + "msg": "world", + }, + ] + `); +}); it('should log array attributes with object child attributes', () => { let [log, writer] = createTLogger(); From 07e4e90b65b0916957b2de420cc1a8c521730d32 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Fri, 18 Apr 2025 19:52:15 +0200 Subject: [PATCH 087/157] async dispose --- packages/logger/package.json | 1 + packages/logger/src/Logger.ts | 7 +++-- packages/logger/tests/Logger.test.ts | 41 +++++++++++++++++++++++++++- yarn.lock | 1 + 4 files changed, 47 insertions(+), 3 deletions(-) diff --git a/packages/logger/package.json b/packages/logger/package.json index 0c8b340ca..ec6bf9490 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -50,6 +50,7 @@ }, "devDependencies": { "@types/quick-format-unescaped": "^4.0.3", + "@whatwg-node/disposablestack": "^0.0.6", "fast-safe-stringify": "^2.1.1", "pkgroll": "2.11.2", "quick-format-unescaped": "^4.0.4" diff --git a/packages/logger/src/Logger.ts b/packages/logger/src/Logger.ts index 0966c4b4f..667e2e121 100644 --- a/packages/logger/src/Logger.ts +++ b/packages/logger/src/Logger.ts @@ -1,3 +1,4 @@ +import { DisposableSymbols } from '@whatwg-node/disposablestack'; import fastSafeStringify from 'fast-safe-stringify'; import format from 'quick-format-unescaped'; import { @@ -43,7 +44,7 @@ export interface LoggerOptions { writers?: [LogWriter, ...LogWriter[]]; } -export class Logger implements LogWriter { +export class Logger implements LogWriter, AsyncDisposable { #level: MaybeLazy; #prefix: string | undefined; #attrs: Attributes | undefined; @@ -120,7 +121,9 @@ export class Logger implements LogWriter { return; } - // TODO: flush on dispose + async [DisposableSymbols.asyncDispose]() { + return this.flush(); + } // diff --git a/packages/logger/tests/Logger.test.ts b/packages/logger/tests/Logger.test.ts index f1c5117f2..6c449f9bc 100644 --- a/packages/logger/tests/Logger.test.ts +++ b/packages/logger/tests/Logger.test.ts @@ -1,7 +1,7 @@ import { setTimeout } from 'node:timers/promises'; import { expect, it, vi } from 'vitest'; import { Logger, LoggerOptions } from '../src/Logger'; -import { LogWriter, MemoryLogWriter } from '../src/writers'; +import { MemoryLogWriter } from '../src/writers'; function createTLogger(opts?: Partial) { const writer = new MemoryLogWriter(); @@ -317,6 +317,45 @@ it('should wait for async writers on flush', async () => { `); }); +it('should wait for async writers on async dispose', async () => { + const logs: any[] = []; + + { + await using log = new Logger({ + writers: [ + { + async write(level, attrs, msg) { + await setTimeout(10); + logs.push({ level, attrs, msg }); + }, + }, + ], + }); + + log.info('hello'); + log.info('world'); + + // not flushed yet + expect(logs).toMatchInlineSnapshot(`[]`); + } + + // flushed because scope ended and async dispose was called + expect(logs).toMatchInlineSnapshot(` + [ + { + "attrs": undefined, + "level": "info", + "msg": "hello", + }, + { + "attrs": undefined, + "level": "info", + "msg": "world", + }, + ] + `); +}); + it('should log array attributes with object child attributes', () => { let [log, writer] = createTLogger(); diff --git a/yarn.lock b/yarn.lock index 611bbd079..95ed34b7d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4131,6 +4131,7 @@ __metadata: resolution: "@graphql-hive/logger@workspace:packages/logger" dependencies: "@types/quick-format-unescaped": "npm:^4.0.3" + "@whatwg-node/disposablestack": "npm:^0.0.6" fast-safe-stringify: "npm:^2.1.1" pkgroll: "npm:2.11.2" quick-format-unescaped: "npm:^4.0.4" From 8226590182126bcd7d56a4fbeb013ae61b34e155 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Fri, 18 Apr 2025 19:52:18 +0200 Subject: [PATCH 088/157] logger doesnt need to implement logwriter --- packages/logger/src/Logger.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/logger/src/Logger.ts b/packages/logger/src/Logger.ts index 667e2e121..da27db5de 100644 --- a/packages/logger/src/Logger.ts +++ b/packages/logger/src/Logger.ts @@ -44,7 +44,7 @@ export interface LoggerOptions { writers?: [LogWriter, ...LogWriter[]]; } -export class Logger implements LogWriter, AsyncDisposable { +export class Logger implements AsyncDisposable { #level: MaybeLazy; #prefix: string | undefined; #attrs: Attributes | undefined; From 94c54e7f5f333cacad71382f7986a9eeea01f116 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Fri, 18 Apr 2025 20:01:46 +0200 Subject: [PATCH 089/157] better plain object detector and use toJSON --- packages/logger/src/utils.ts | 22 ++++++++++++++++++---- packages/logger/tests/Logger.test.ts | 28 ++++++++++++++++++++++++++-- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/packages/logger/src/utils.ts b/packages/logger/src/utils.ts index 4e5e55db3..784229fbe 100644 --- a/packages/logger/src/utils.ts +++ b/packages/logger/src/utils.ts @@ -74,7 +74,7 @@ export function parseAttrs( return attrs.map((val) => unwrapAttrVal(val)); } - if (Object.prototype.toString.call(attrs) === '[object Object]') { + if (isPlainObject(attrs)) { const unwrapped: Attributes = {}; for (const key of Object.keys(attrs)) { const val = attrs[key as keyof typeof attrs]; @@ -103,9 +103,7 @@ function unwrapAttrVal(attr: AttributeValue): AttributeValue { return attr.map((val) => unwrapAttrVal(val)); } - // plain object (not an instance of anything) - // NOTE: is valnurable to `Symbol.toStringTag` pollution, but the user would be sabotaging themselves - if (Object.prototype.toString.call(attr) === '[object Object]') { + if (isPlainObject(attr)) { const unwrapped: { [key: string | number]: AttributeValue } = {}; for (const key of Object.keys(attr)) { const val = attr[key as keyof typeof attr]; @@ -127,6 +125,14 @@ function objectifyClass(val: unknown): Record { // TODO: this should never happen, objectify class should not be called on empty values return {}; } + if ( + typeof val === 'object' && + 'toJSON' in val && + typeof val.toJSON === 'function' + ) { + // if the object has a toJSON method, use it - always + return val.toJSON(); + } const props: Record = {}; for (const propName of Object.getOwnPropertyNames(val)) { props[propName] = val[propName as keyof typeof val]; @@ -186,3 +192,11 @@ export function shallowMergeAttributes( return undefined; } } + +/** Checks whether the value is a plan object and not an instance of any other class. */ +function isPlainObject(val: unknown): val is Object { + return ( + Object(val).constructor === Object && + Object.getPrototypeOf(val) === Object.prototype + ); +} diff --git a/packages/logger/tests/Logger.test.ts b/packages/logger/tests/Logger.test.ts index 6c449f9bc..fc1bdd9cd 100644 --- a/packages/logger/tests/Logger.test.ts +++ b/packages/logger/tests/Logger.test.ts @@ -498,6 +498,8 @@ it.skipIf( }, { "attrs": { + "class": "MyClass", + "getsomeprop": "hey", "someprop": "hey", }, "level": "info", @@ -506,11 +508,33 @@ it.skipIf( `); }); -it.todo('should serialise aggregate errors'); +it('should serialise using the toJSON method', () => { + const [log, writer] = createTLogger(); + + class ToJSON { + toJSON() { + return { hello: 'world' }; + } + } + + log.info(new ToJSON(), 'hello'); + + expect(writer.logs).toMatchInlineSnapshot(` + [ + { + "attrs": { + "hello": "world", + }, + "level": "info", + "msg": "hello", + }, + ] + `); +}); it.todo('should serialise error causes'); -it.todo('should serialise using the toJSON method'); +it.todo('should serialise aggregate errors'); it('should change log level', () => { const [log, writer] = createTLogger(); From f886b0f3d1b1afd7d5d061c9daf2a2c1e5f9a1ce Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Fri, 18 Apr 2025 20:18:07 +0200 Subject: [PATCH 090/157] improve objectify and serialise and tests --- packages/logger/src/utils.ts | 12 +++-- packages/logger/tests/Logger.test.ts | 69 +++++++++++++++++++++++++++- 2 files changed, 76 insertions(+), 5 deletions(-) diff --git a/packages/logger/src/utils.ts b/packages/logger/src/utils.ts index 784229fbe..c81117150 100644 --- a/packages/logger/src/utils.ts +++ b/packages/logger/src/utils.ts @@ -121,8 +121,12 @@ function isPrimitive(val: unknown): val is string | number | boolean { } function objectifyClass(val: unknown): Record { - if (!val) { - // TODO: this should never happen, objectify class should not be called on empty values + if ( + // simply empty + !val || + // Object.create(null) + Object(val).__proto__ == null + ) { return {}; } if ( @@ -135,7 +139,7 @@ function objectifyClass(val: unknown): Record { } const props: Record = {}; for (const propName of Object.getOwnPropertyNames(val)) { - props[propName] = val[propName as keyof typeof val]; + props[propName] = unwrapAttrVal(val[propName as keyof typeof val]); } for (const protoPropName of Object.getOwnPropertyNames( Object.getPrototypeOf(val), @@ -144,7 +148,7 @@ function objectifyClass(val: unknown): Record { if (typeof propVal === 'function') { continue; } - props[protoPropName] = propVal; + props[protoPropName] = unwrapAttrVal(propVal); } return { ...props, diff --git a/packages/logger/tests/Logger.test.ts b/packages/logger/tests/Logger.test.ts index fc1bdd9cd..0bf7f6e3e 100644 --- a/packages/logger/tests/Logger.test.ts +++ b/packages/logger/tests/Logger.test.ts @@ -532,7 +532,74 @@ it('should serialise using the toJSON method', () => { `); }); -it.todo('should serialise error causes'); +it('should serialise error causes', () => { + const [log, writer] = createTLogger(); + + const cause = new Error('Cause'); + cause.stack = ''; + + const err = new Error('Woah!', { cause }); + err.stack = ''; + + log.info(err, 'hello'); + + expect(writer.logs).toMatchInlineSnapshot(` + [ + { + "attrs": { + "cause": { + "class": "Error", + "message": "Cause", + "name": "Error", + "stack": "", + }, + "class": "Error", + "message": "Woah!", + "name": "Error", + "stack": "", + }, + "level": "info", + "msg": "hello", + }, + ] + `); +}); + +it('should gracefully handle Object.create(null)', () => { + const [log, writer] = createTLogger(); + + class NullConst { + constructor() { + return Object.create(null); + } + } + class NullProp { + someprop = Object.create(null); + } + + log.info({ class: new NullConst() }, 'hello'); + log.info(new NullProp(), 'world'); + + expect(writer.logs).toMatchInlineSnapshot(` + [ + { + "attrs": { + "class": {}, + }, + "level": "info", + "msg": "hello", + }, + { + "attrs": { + "class": "NullProp", + "someprop": {}, + }, + "level": "info", + "msg": "world", + }, + ] + `); +}); it.todo('should serialise aggregate errors'); From 9f5cde45fc81eb8ad33e5fb95150ebbda54ff727 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Fri, 18 Apr 2025 20:24:09 +0200 Subject: [PATCH 091/157] handle circular --- packages/logger/src/utils.ts | 12 +++++++++-- packages/logger/tests/Logger.test.ts | 31 ++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/packages/logger/src/utils.ts b/packages/logger/src/utils.ts index c81117150..ceff37e69 100644 --- a/packages/logger/src/utils.ts +++ b/packages/logger/src/utils.ts @@ -86,7 +86,10 @@ export function parseAttrs( return objectifyClass(attrs); } -function unwrapAttrVal(attr: AttributeValue): AttributeValue { +function unwrapAttrVal( + attr: AttributeValue, + visited = new WeakSet(), +): AttributeValue { if (!attr) { return attr; } @@ -99,6 +102,11 @@ function unwrapAttrVal(attr: AttributeValue): AttributeValue { return `[Function: ${attr.name || '(anonymous)'}]`; } + if (visited.has(attr)) { + return '[Circular]'; + } + visited.add(attr); + if (Array.isArray(attr)) { return attr.map((val) => unwrapAttrVal(val)); } @@ -107,7 +115,7 @@ function unwrapAttrVal(attr: AttributeValue): AttributeValue { const unwrapped: { [key: string | number]: AttributeValue } = {}; for (const key of Object.keys(attr)) { const val = attr[key as keyof typeof attr]; - unwrapped[key] = unwrapAttrVal(val); + unwrapped[key] = unwrapAttrVal(val, visited); } return unwrapped; } diff --git a/packages/logger/tests/Logger.test.ts b/packages/logger/tests/Logger.test.ts index 0bf7f6e3e..8c760e3ea 100644 --- a/packages/logger/tests/Logger.test.ts +++ b/packages/logger/tests/Logger.test.ts @@ -601,6 +601,37 @@ it('should gracefully handle Object.create(null)', () => { `); }); +it('should handle circular references', () => { + const [log, writer] = createTLogger(); + + const obj = { circ: null as any }; + const circ = { + hello: 'world', + obj, + }; + obj.circ = circ; + + log.info(circ, 'circular'); + + expect(writer.logs).toMatchInlineSnapshot(` + [ + { + "attrs": { + "hello": "world", + "obj": { + "circ": { + "hello": "world", + "obj": "[Circular]", + }, + }, + }, + "level": "info", + "msg": "circular", + }, + ] + `); +}); + it.todo('should serialise aggregate errors'); it('should change log level', () => { From 9caea49aaff39adcff562eff6d71007ea5f1c955 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Fri, 18 Apr 2025 20:26:00 +0200 Subject: [PATCH 092/157] test aggregate errors --- packages/logger/tests/Logger.test.ts | 44 +++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/packages/logger/tests/Logger.test.ts b/packages/logger/tests/Logger.test.ts index 8c760e3ea..1683d8b62 100644 --- a/packages/logger/tests/Logger.test.ts +++ b/packages/logger/tests/Logger.test.ts @@ -632,7 +632,49 @@ it('should handle circular references', () => { `); }); -it.todo('should serialise aggregate errors'); +it('should serialise aggregate errors', () => { + const [log, writer] = createTLogger(); + + const err1 = new Error('Woah!'); + err1.stack = '<1 stack>'; + + const err2 = new Error('Woah2!'); + err2.stack = '<2 stack>'; + + const aggErr = new AggregateError([err1, err2], 'Woah Aggregate!'); + aggErr.stack = ''; + + log.info(aggErr, 'aggregate'); + + expect(writer.logs).toMatchInlineSnapshot(` + [ + { + "attrs": { + "class": "AggregateError", + "errors": [ + { + "class": "Error", + "message": "Woah!", + "name": "Error", + "stack": "<1 stack>", + }, + { + "class": "Error", + "message": "Woah2!", + "name": "Error", + "stack": "<2 stack>", + }, + ], + "message": "Woah Aggregate!", + "name": "AggregateError", + "stack": "", + }, + "level": "info", + "msg": "aggregate", + }, + ] + `); +}); it('should change log level', () => { const [log, writer] = createTLogger(); From 3b81bae73647060c9ab9b96d2e7d4091de85b71d Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Fri, 18 Apr 2025 20:31:57 +0200 Subject: [PATCH 093/157] stable errors for testing --- packages/logger/tests/Logger.test.ts | 62 ++++++++++++++++------------ 1 file changed, 36 insertions(+), 26 deletions(-) diff --git a/packages/logger/tests/Logger.test.ts b/packages/logger/tests/Logger.test.ts index 1683d8b62..8062b573d 100644 --- a/packages/logger/tests/Logger.test.ts +++ b/packages/logger/tests/Logger.test.ts @@ -11,14 +11,10 @@ function createTLogger(opts?: Partial) { ] as const; } -it.skipIf( - // skip on bun because bun serialises errors differently from node (failing the snapshot) - globalThis.Bun, -)('should write logs with levels, message and attributes', () => { +it('should write logs with levels, message and attributes', () => { const [log, writter] = createTLogger(); - const err = new Error('Woah!'); - err.stack = ''; + const err = stableError(new Error('Woah!')); log.log('info'); log.log('info', { hello: 'world', err }, 'Hello, world!'); @@ -446,14 +442,10 @@ it('should format string', () => { `); }); -it.skipIf( - // skip on bun because bun serialises errors differently from node (failing the snapshot) - globalThis.Bun, -)('should write logs with unexpected attributes', () => { +it('should write logs with unexpected attributes', () => { const [log, writer] = createTLogger(); - const err = new Error('Woah!'); - err.stack = ''; + const err = stableError(new Error('Woah!')); log.info(err); @@ -535,11 +527,9 @@ it('should serialise using the toJSON method', () => { it('should serialise error causes', () => { const [log, writer] = createTLogger(); - const cause = new Error('Cause'); - cause.stack = ''; + const cause = stableError(new Error('Cause')); - const err = new Error('Woah!', { cause }); - err.stack = ''; + const err = stableError(new Error('Woah!', { cause })); log.info(err, 'hello'); @@ -551,7 +541,7 @@ it('should serialise error causes', () => { "class": "Error", "message": "Cause", "name": "Error", - "stack": "", + "stack": "", }, "class": "Error", "message": "Woah!", @@ -635,14 +625,13 @@ it('should handle circular references', () => { it('should serialise aggregate errors', () => { const [log, writer] = createTLogger(); - const err1 = new Error('Woah!'); - err1.stack = '<1 stack>'; + const err1 = stableError(new Error('Woah!')); - const err2 = new Error('Woah2!'); - err2.stack = '<2 stack>'; + const err2 = stableError(new Error('Woah2!')); - const aggErr = new AggregateError([err1, err2], 'Woah Aggregate!'); - aggErr.stack = ''; + const aggErr = stableError( + new AggregateError([err1, err2], 'Woah Aggregate!'), + ); log.info(aggErr, 'aggregate'); @@ -656,18 +645,18 @@ it('should serialise aggregate errors', () => { "class": "Error", "message": "Woah!", "name": "Error", - "stack": "<1 stack>", + "stack": "", }, { "class": "Error", "message": "Woah2!", "name": "Error", - "stack": "<2 stack>", + "stack": "", }, ], "message": "Woah Aggregate!", "name": "AggregateError", - "stack": "", + "stack": "", }, "level": "info", "msg": "aggregate", @@ -745,3 +734,24 @@ it('should change child log level only on child', () => { ] `); }); + +/** Stabilises the error for snapshot testing */ +function stableError(err: T): T { + if (globalThis.Bun) { + // bun serialises errors differently from node + // we need to remove some properties to make the snapshots match + // @ts-expect-error + delete err.column; + // @ts-expect-error + delete err.line; + // @ts-expect-error + delete err.originalColumn; + // @ts-expect-error + delete err.originalLine; + // @ts-expect-error + delete err.sourceURL; + } + // we remove the stack to make the snapshot stable + err.stack = ''; + return err; +} From 5779e7f9c51998d8898dc562976803ef94f8d700 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Fri, 18 Apr 2025 20:36:27 +0200 Subject: [PATCH 094/157] fix types for memorylogwriter --- packages/logger/src/writers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/logger/src/writers.ts b/packages/logger/src/writers.ts index 77c1bfbd1..0e7ce562f 100644 --- a/packages/logger/src/writers.ts +++ b/packages/logger/src/writers.ts @@ -20,7 +20,7 @@ export class MemoryLogWriter implements LogWriter { public logs: { level: LogLevel; msg?: string; attrs?: unknown }[] = []; write( level: LogLevel, - attrs: Record, + attrs: Attributes | null | undefined, msg: string | null | undefined, ): void { this.logs.push({ From b584e639b9109b5a57e3dc8e52cfa4f09460784f Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Fri, 18 Apr 2025 21:20:05 +0200 Subject: [PATCH 095/157] pretty attrs stringify --- packages/logger/src/writers.ts | 38 +++++++++++++++++++++++++-- packages/logger/tests/writers.test.ts | 3 +++ 2 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 packages/logger/tests/writers.test.ts diff --git a/packages/logger/src/writers.ts b/packages/logger/src/writers.ts index 0e7ce562f..92d510263 100644 --- a/packages/logger/src/writers.ts +++ b/packages/logger/src/writers.ts @@ -68,11 +68,45 @@ export class ConsoleLogWriter implements LogWriter { this.color('timestamp', new Date().toISOString()), this.color(level, logLevelToString(level)), this.color('message', msg), - // we want to stringify because we want all properties (even nested ones)be properly displayed - attrs ? jsonStringify(attrs, truthyEnv('LOG_JSON_PRETTY')) : undefined, + attrs ? this.stringifyAttrs(attrs) : undefined, ].join(' '), ); } + stringifyAttrs(attrs: Attributes): string { + let log = '\n'; + + for (const line of jsonStringify(attrs, true).split('\n')) { + // remove the first and last line the opening and closing brackets + if (line === '{' || line === '}' || line === '[' || line === ']') { + continue; + } + + let formattedLine = line; + + // remove the quotes from the keys and remove the opening bracket + formattedLine = formattedLine.replace(/"([^"]+)":/, '$1:'); + + // replace all escaped new lines with a new line and append the indentation of the line + let indentationSize = line.match(/^\s*/)?.[0]?.length || 0; + if (indentationSize) indentationSize++; + + // TODO: error stack traces will have 4 spaces of indentation, should we sanitize all 4 spaces / tabs to 2 space indentation? + formattedLine = formattedLine.replaceAll( + /\\n/g, + '\n' + [...Array(indentationSize)].join(' '), + ); + + // remove the ending comma + formattedLine = formattedLine.replace(/,$/, ''); + + log += formattedLine + '\n'; + } + + // remove last new line + log = log.slice(0, -1); + + return log; + } } export class JSONLogWriter implements LogWriter { diff --git a/packages/logger/tests/writers.test.ts b/packages/logger/tests/writers.test.ts new file mode 100644 index 000000000..a5ffd3cc5 --- /dev/null +++ b/packages/logger/tests/writers.test.ts @@ -0,0 +1,3 @@ +import { it } from 'vitest'; + +it.todo('should pretty print the attributes when using the console log writer'); From e9ca3a62667046cb2c6c0dc04ea4641f395619c2 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Fri, 18 Apr 2025 21:21:09 +0200 Subject: [PATCH 096/157] nofroget --- packages/logger/src/writers.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/logger/src/writers.ts b/packages/logger/src/writers.ts index 92d510263..eb3f402cf 100644 --- a/packages/logger/src/writers.ts +++ b/packages/logger/src/writers.ts @@ -84,6 +84,7 @@ export class ConsoleLogWriter implements LogWriter { let formattedLine = line; // remove the quotes from the keys and remove the opening bracket + // TODO: make sure keys with quotes are preserved formattedLine = formattedLine.replace(/"([^"]+)":/, '$1:'); // replace all escaped new lines with a new line and append the indentation of the line From 4dc404c0644dab5267fb926d540269b87a96eb72 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Sun, 20 Apr 2025 15:16:27 +0200 Subject: [PATCH 097/157] color --- packages/logger/src/writers.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/logger/src/writers.ts b/packages/logger/src/writers.ts index eb3f402cf..366d11805 100644 --- a/packages/logger/src/writers.ts +++ b/packages/logger/src/writers.ts @@ -39,6 +39,7 @@ const asciMap = { warn: '\x1b[33m', // yellow error: '\x1b[41;39m', // red; white message: '\x1b[1m', // bold + key: '\x1b[35m', // magenta reset: '\x1b[0m', // reset }; @@ -49,14 +50,17 @@ export class ConsoleLogWriter implements LogWriter { typeof process === 'undefined' || // no color if https://no-color.org/ truthyEnv('NO_COLOR'); - color(style: keyof typeof asciMap, text: string | null | undefined) { + color( + style: keyof typeof asciMap, + text: T, + ): T { if (!text) { return text; } if (this.#nocolor) { return text; } - return asciMap[style] + text + asciMap.reset; + return (asciMap[style] + text + asciMap.reset) as T; } write( level: LogLevel, @@ -85,7 +89,10 @@ export class ConsoleLogWriter implements LogWriter { // remove the quotes from the keys and remove the opening bracket // TODO: make sure keys with quotes are preserved - formattedLine = formattedLine.replace(/"([^"]+)":/, '$1:'); + formattedLine = formattedLine.replace( + /"([^"]+)":/, + this.color('key', '$1:'), + ); // replace all escaped new lines with a new line and append the indentation of the line let indentationSize = line.match(/^\s*/)?.[0]?.length || 0; From fb0a4a0c6660796ccb237e4ce2f41b674e290f40 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Sun, 20 Apr 2025 15:44:26 +0200 Subject: [PATCH 098/157] test console writer --- packages/logger/src/writers.ts | 51 ++++++++++--- packages/logger/tests/Logger.test.ts | 22 +----- .../tests/__snapshots__/writers.test.ts.snap | 62 ++++++++++++++++ packages/logger/tests/utils.ts | 20 ++++++ packages/logger/tests/writers.test.ts | 72 ++++++++++++++++++- 5 files changed, 193 insertions(+), 34 deletions(-) create mode 100644 packages/logger/tests/__snapshots__/writers.test.ts.snap create mode 100644 packages/logger/tests/utils.ts diff --git a/packages/logger/src/writers.ts b/packages/logger/src/writers.ts index 366d11805..21e2f8bde 100644 --- a/packages/logger/src/writers.ts +++ b/packages/logger/src/writers.ts @@ -43,13 +43,40 @@ const asciMap = { reset: '\x1b[0m', // reset }; +export interface ConsoleLogWriterOptions { + /** @default globalThis.Console */ + console?: Pick; + /** + * Whether to disable colors in the console output. + * + * @default env.NO_COLOR || false + */ + noColor?: boolean; + /** + * Whether to include the timestamp at the beginning of the log message. + * + * @default false + */ + noTimestamp?: boolean; +} + export class ConsoleLogWriter implements LogWriter { - #nocolor = - // no color if we're running in browser-like (edge) environments - // TODO: is this the most accurate way to detect it? - typeof process === 'undefined' || - // no color if https://no-color.org/ - truthyEnv('NO_COLOR'); + #console: NonNullable; + #noColor: boolean; + #noTimestamp: boolean; + constructor(opts: ConsoleLogWriterOptions = {}) { + const { + console = globalThis.console, + // no color if we're running in browser-like (edge) environments + noColor = typeof process === 'undefined' || + // or no color if https://no-color.org/ + truthyEnv('NO_COLOR'), + noTimestamp = false, + } = opts; + this.#console = console; + this.#noColor = noColor; + this.#noTimestamp = noTimestamp; + } color( style: keyof typeof asciMap, text: T, @@ -57,7 +84,7 @@ export class ConsoleLogWriter implements LogWriter { if (!text) { return text; } - if (this.#nocolor) { + if (this.#noColor) { return text; } return (asciMap[style] + text + asciMap.reset) as T; @@ -67,13 +94,15 @@ export class ConsoleLogWriter implements LogWriter { attrs: Attributes | null | undefined, msg: string | null | undefined, ): void { - console[level === 'trace' ? 'debug' : level]( + this.#console[level === 'trace' ? 'debug' : level]( [ - this.color('timestamp', new Date().toISOString()), + !this.#noTimestamp && this.color('timestamp', new Date().toISOString()), this.color(level, logLevelToString(level)), this.color('message', msg), - attrs ? this.stringifyAttrs(attrs) : undefined, - ].join(' '), + attrs && this.stringifyAttrs(attrs), + ] + .filter(Boolean) + .join(' '), ); } stringifyAttrs(attrs: Attributes): string { diff --git a/packages/logger/tests/Logger.test.ts b/packages/logger/tests/Logger.test.ts index 8062b573d..dca3a56e6 100644 --- a/packages/logger/tests/Logger.test.ts +++ b/packages/logger/tests/Logger.test.ts @@ -2,6 +2,7 @@ import { setTimeout } from 'node:timers/promises'; import { expect, it, vi } from 'vitest'; import { Logger, LoggerOptions } from '../src/Logger'; import { MemoryLogWriter } from '../src/writers'; +import { stableError } from './utils'; function createTLogger(opts?: Partial) { const writer = new MemoryLogWriter(); @@ -734,24 +735,3 @@ it('should change child log level only on child', () => { ] `); }); - -/** Stabilises the error for snapshot testing */ -function stableError(err: T): T { - if (globalThis.Bun) { - // bun serialises errors differently from node - // we need to remove some properties to make the snapshots match - // @ts-expect-error - delete err.column; - // @ts-expect-error - delete err.line; - // @ts-expect-error - delete err.originalColumn; - // @ts-expect-error - delete err.originalLine; - // @ts-expect-error - delete err.sourceURL; - } - // we remove the stack to make the snapshot stable - err.stack = ''; - return err; -} diff --git a/packages/logger/tests/__snapshots__/writers.test.ts.snap b/packages/logger/tests/__snapshots__/writers.test.ts.snap new file mode 100644 index 000000000..2fe56a003 --- /dev/null +++ b/packages/logger/tests/__snapshots__/writers.test.ts.snap @@ -0,0 +1,62 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ConsoleLogWriter should color levels and keys 1`] = ` +[ + "TRC hi + hello: "world"", + "DBG hi + hello: "world"", + "INF hi + hello: "world"", + "WRN hi + hello: "world"", + "ERR hi + hello: "world"", +] +`; + +exports[`ConsoleLogWriter should pretty print the attributes 1`] = ` +[ + "TRC obj + a: 1 + b: 2", + "DBG arr + "a" + "b" + "c"", + "INF nested + a: { + b: { + c: { + d: 1 + } + } + }", + "WRN arr objs + { + a: 1 + } + { + b: 2 + }", + "ERR multlinestring + str: "a + b + c" + err: { + stack: "" + message: "woah!" + name: "Error" + class: "Error" + }", + "INF graphql + query: " + { + hi(howMany: 1) { + hello + world + } + } + "", +] +`; diff --git a/packages/logger/tests/utils.ts b/packages/logger/tests/utils.ts new file mode 100644 index 000000000..1c448beca --- /dev/null +++ b/packages/logger/tests/utils.ts @@ -0,0 +1,20 @@ +/** Stabilises the error for snapshot testing */ +export function stableError(err: T): T { + if (globalThis.Bun) { + // bun serialises errors differently from node + // we need to remove some properties to make the snapshots match + // @ts-expect-error + delete err.column; + // @ts-expect-error + delete err.line; + // @ts-expect-error + delete err.originalColumn; + // @ts-expect-error + delete err.originalLine; + // @ts-expect-error + delete err.sourceURL; + } + // we remove the stack to make the snapshot stable + err.stack = ''; + return err; +} diff --git a/packages/logger/tests/writers.test.ts b/packages/logger/tests/writers.test.ts index a5ffd3cc5..de6f60c59 100644 --- a/packages/logger/tests/writers.test.ts +++ b/packages/logger/tests/writers.test.ts @@ -1,3 +1,71 @@ -import { it } from 'vitest'; +import { describe, expect, it } from 'vitest'; +import { Logger } from '../src/Logger'; +import { ConsoleLogWriter, ConsoleLogWriterOptions } from '../src/writers'; +import { stableError } from './utils'; -it.todo('should pretty print the attributes when using the console log writer'); +describe('ConsoleLogWriter', () => { + function createTConsoleLogger(opts?: Partial) { + const logs: string[] = []; + const writer = new ConsoleLogWriter({ + console: { + debug: (...args: unknown[]) => { + logs.push(args.join(' ')); + }, + info: (...args: unknown[]) => { + logs.push(args.join(' ')); + }, + warn: (...args: unknown[]) => { + logs.push(args.join(' ')); + }, + error: (...args: unknown[]) => { + logs.push(args.join(' ')); + }, + }, + noTimestamp: true, + noColor: true, + ...opts, + }); + return [new Logger({ level: 'trace', writers: [writer] }), logs] as const; + } + + it('should pretty print the attributes', () => { + const [log, logs] = createTConsoleLogger(); + + log.trace({ a: 1, b: 2 }, 'obj'); + log.debug(['a', 'b', 'c'], 'arr'); + log.info({ a: { b: { c: { d: 1 } } } }, 'nested'); + log.warn([{ a: 1 }, { b: 2 }], 'arr objs'); + log.error( + { str: 'a\nb\nc', err: stableError(new Error('woah!')) }, + 'multlinestring', + ); + + log.info( + { + query: ` +{ + hi(howMany: 1) { + hello + world + } +} +`, + }, + 'graphql', + ); + + expect(logs).toMatchSnapshot(); + }); + + it('should color levels and keys', () => { + const [log, logs] = createTConsoleLogger({ noColor: false }); + + log.trace({ hello: 'world' }, 'hi'); + log.debug({ hello: 'world' }, 'hi'); + log.info({ hello: 'world' }, 'hi'); + log.warn({ hello: 'world' }, 'hi'); + log.error({ hello: 'world' }, 'hi'); + + expect(logs).toMatchSnapshot(); + }); +}); From 85adf514db4c51eff3641acef162b5a97f7f81e0 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Sun, 20 Apr 2025 15:50:00 +0200 Subject: [PATCH 099/157] no flush --- packages/nestjs/src/index.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/nestjs/src/index.ts b/packages/nestjs/src/index.ts index 7e772587f..44e5be704 100644 --- a/packages/nestjs/src/index.ts +++ b/packages/nestjs/src/index.ts @@ -98,9 +98,6 @@ export class HiveGatewayDriver< nestLog[level](msg, attrs); } }, - flush() { - // noop - }, }, ], }); From 7416692adcdf450cee0c7cb51854b7e921ad2046 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Sun, 20 Apr 2025 16:15:24 +0200 Subject: [PATCH 100/157] json log on log json --- packages/logger/src/Logger.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/logger/src/Logger.ts b/packages/logger/src/Logger.ts index da27db5de..e48d8b80d 100644 --- a/packages/logger/src/Logger.ts +++ b/packages/logger/src/Logger.ts @@ -12,7 +12,7 @@ import { shouldLog, truthyEnv, } from './utils'; -import { ConsoleLogWriter, LogWriter } from './writers'; +import { ConsoleLogWriter, JSONLogWriter, LogWriter } from './writers'; export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error'; @@ -39,7 +39,7 @@ export interface LoggerOptions { /** * The log writers to use when writing logs. * - * @default [new ConsoleLogWriter()] + * @default env.LOG_JSON ? [new JSONLogWriter()] : [new ConsoleLogWriter()] */ writers?: [LogWriter, ...LogWriter[]]; } @@ -64,7 +64,11 @@ export class Logger implements AsyncDisposable { (truthyEnv('DEBUG') ? 'debug' : 'info'); this.#prefix = opts.prefix; this.#attrs = opts.attrs; - this.#writers = opts.writers ?? [new ConsoleLogWriter()]; + this.#writers = + opts.writers ?? + (truthyEnv('LOG_JSON') + ? [new JSONLogWriter()] + : [new ConsoleLogWriter()]); } /** The prefix that's prepended to each log message. */ From e54aad98e704edb5817f2d54aad3ef6c6700e97e Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Sun, 20 Apr 2025 16:25:32 +0200 Subject: [PATCH 101/157] begin readme --- packages/logger/README.md | 75 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 packages/logger/README.md diff --git a/packages/logger/README.md b/packages/logger/README.md new file mode 100644 index 000000000..300e84e7d --- /dev/null +++ b/packages/logger/README.md @@ -0,0 +1,75 @@ +# Hive Logger + +Lightweight and customizable logging utility designed for use within the GraphQL Hive ecosystem. It provides structured logging capabilities, making it easier to debug and monitor applications effectively. + +## Compatibility + +The Hive Logger is designed to work seamlessly in all JavaScript environments, including Node.js, browsers, and serverless platforms. Its lightweight design ensures minimal overhead, making it suitable for a wide range of applications. + +# Getting started + +## Install + +```sh +npm i @graphql-hive/logger +``` + +## Usage + +Create a default logger that set to the `info` log level writing to the console. + +```ts +import { Logger } from '@graphql-hive/logger'; + +const log = new Logger(); + +log.debug('I wont be logged by default'); + +log.info({ some: 'attributes' }, 'Hello %s!', 'world'); + +const child = log.child({ requestId: '123-456' }); + +child.warn({ more: 'attributes' }, 'Oh hello child!'); + +const err = new Error('Woah!'); + +child.error({ err }, 'Something went wrong!'); +``` + +Will produce the following output to the console output: + + +```sh +$ node example.js + +2025-04-10T14:00:00.000Z INF Hello world! + some: "attributes" +2025-04-10T14:00:00.000Z WRN Oh hello child! + requestId: "123-456" + more: "attributes" +2025-04-10T14:00:00.000Z ERR Something went wrong! + requestId: "123-456" + err: { + stack: "Error: Woah! + at (/project/example.js:13:13) + at ModuleJob.run (node:internal/modules/esm/module_job:274:25) + at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:644:26) + at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:98:5)" + message: "Woah!" + name: "Error" + class: "Error" + } +``` + + +or if you wish to have JSON output, set the `LOG_JSON` environment variable to a truthy value: + + +```sh +$ LOG_JSON=1 node example.js + +{"some":"attributes","level":"info","msg":"Hello world!","timestamp":"2025-04-10T14:00:00.000Z"} +{"requestId":"123-456","more":"attributes","level":"info","msg":"Hello child!","timestamp":"2025-04-10T14:00:00.000Z"} +{"requestId":"123-456","err":{"stack":"Error: Woah!\n at (/project/example.js:13:13)\n at ModuleJob.run (node:internal/modules/esm/module_job:274:25)\n at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:644:26)\n at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:98:5)","message":"Woah!","name":"Error","class":"Error"},"level":"error","msg":"Something went wrong!","timestamp":"2025-04-10T14:00:00.000Z"} +``` + From e4c6df62c47d5d980517752d81e91dac1f3bec43 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Sun, 20 Apr 2025 16:44:25 +0200 Subject: [PATCH 102/157] more --- packages/logger/README.md | 67 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 63 insertions(+), 4 deletions(-) diff --git a/packages/logger/README.md b/packages/logger/README.md index 300e84e7d..fb806dfa7 100644 --- a/packages/logger/README.md +++ b/packages/logger/README.md @@ -6,7 +6,7 @@ Lightweight and customizable logging utility designed for use within the GraphQL The Hive Logger is designed to work seamlessly in all JavaScript environments, including Node.js, browsers, and serverless platforms. Its lightweight design ensures minimal overhead, making it suitable for a wide range of applications. -# Getting started +# Getting Started ## Install @@ -14,7 +14,7 @@ The Hive Logger is designed to work seamlessly in all JavaScript environments, i npm i @graphql-hive/logger ``` -## Usage +## Basic Usage Create a default logger that set to the `info` log level writing to the console. @@ -40,8 +40,6 @@ Will produce the following output to the console output: ```sh -$ node example.js - 2025-04-10T14:00:00.000Z INF Hello world! some: "attributes" 2025-04-10T14:00:00.000Z WRN Oh hello child! @@ -73,3 +71,64 @@ $ LOG_JSON=1 node example.js {"requestId":"123-456","err":{"stack":"Error: Woah!\n at (/project/example.js:13:13)\n at ModuleJob.run (node:internal/modules/esm/module_job:274:25)\n at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:644:26)\n at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:98:5)","message":"Woah!","name":"Error","class":"Error"},"level":"error","msg":"Something went wrong!","timestamp":"2025-04-10T14:00:00.000Z"} ``` + +## Logging Levels + +The default logger uses the `info` log level which will make sure to log only `info`+ logs. Available log levels are: + +- `trace` +- `debug` +- `info` _default_ +- `warn` +- `error` + +You can change the loggers logging level on creation or dynamically. + +```ts +import { Logger } from '@graphql-hive/logger'; + +const log = new Logger({ level: 'debug' }); + +log.trace( + // you can suply "lazy" attributes which wont be evaluated unless the log level allows logging + () => ({ + wont: 'be evaluated', + some: expensiveOperation(), + }), + 'Wont be logged and attributes wont be evaluated', +); + +log.debug('Hello world!'); + +const child = log.child('[prefix] '); + +child.debug('Child loggers inherit the parent log level'); + +log.setLevel('trace'); + +log.trace(() => ({ hi: 'there' }), 'Now tracing is logged too!'); + +child.trace('Also on the child logger'); + +child.setLevel('info'); + +log.trace('Still logging!'); + +child.debug('Wont be logged because the child has a different log level now'); + +child.info('Hello child!'); +``` + +Outputs the following to the console: + + +```sh +2025-04-10T14:00:00.000Z DBG Hello world! +2025-04-10T14:00:00.000Z DBG [prefix] Child loggers inherit the parent log level +2025-04-10T14:00:00.000Z TRC Now tracing is logged too! + hi: "there" +2025-04-10T14:00:00.000Z TRC [prefix] Also on the child logger +2025-04-10T14:00:00.000Z TRC Still logging! +2025-04-10T14:00:00.000Z INF Hello child! +``` + From b97c6f13ecfbeb3b5f7059bf0352dc2961c05662 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Sun, 20 Apr 2025 17:10:49 +0200 Subject: [PATCH 103/157] writers --- packages/logger/README.md | 138 ++++++++++++++++++++++++++++++++++ packages/logger/src/Logger.ts | 2 + 2 files changed, 140 insertions(+) diff --git a/packages/logger/README.md b/packages/logger/README.md index fb806dfa7..ddc948d07 100644 --- a/packages/logger/README.md +++ b/packages/logger/README.md @@ -132,3 +132,141 @@ Outputs the following to the console: 2025-04-10T14:00:00.000Z INF Hello child! ``` + +## Writers + +Logger writers are responsible for handling how and where log messages are output. In Hive Logger, writers are pluggable components that receive structured log data and determine its final destination and format. This allows you to easily customize logging behavior, such as printing logs to the console, writing them as JSON, storing them in memory for testing, or sending them to external systems. + +By default, Hive Logger provides several built-in writers, but you can also implement your own to suit your application's needs. The built-ins are: + +### `MemoryLogWriter` + +Writes the logs to memory allowing you to access the logs. Mostly useful for testing. + +```ts +import { Logger, MemoryLogWriter } from '@graphql-hive/logger'; + +const writer = new MemoryLogWriter(); + +const log = new Logger({ writers: [writer] }); + +log.info({ my: 'attrs' }, 'Hello World!'); + +console.log(writer.logs); +``` + +Outputs: + +```sh +[ { level: 'info', msg: 'Hello World!', attrs: { my: 'attrs' } } ] +``` + +### `ConsoleLogWriter` (default) + +The default log writer used by the Hive Logger. It outputs log messages to the console in a human-friendly, colorized format, making it easy to distinguish log levels and read structured attributes. Each log entry includes a timestamp, the log level (with color), the message, and any additional attributes (with colored keys), which are pretty-printed and formatted for clarity. + +The writer works in both Node.js and browser-like environments, automatically disabling colors if not supported. This makes `ConsoleLogWriter` ideal for all cases, providing clear and readable logs out of the box. + +```ts +import { ConsoleLogWriter, Logger } from '@graphql-hive/logger'; + +const writer = new ConsoleLogWriter({ + noColor: true, // defaults to env.NO_COLOR. read more: https://no-color.org/ + noTimestamp: true, +}); + +const log = new Logger({ writers: [writer] }); + +log.info({ my: 'attrs' }, 'Hello World!'); +``` + +Outputs: + + +```sh +INF Hello World! + my: "attrs" +``` + + +### `JSONLogWriter` (default when `LOG_JSON=1`) + +Built-in log writer that outputs each log entry as a structured JSON object. When used, it prints logs to the console in JSON format, including all provided attributes, the log level, message, and a timestamp. + +If the `LOG_JSON_PRETTY=1` environment variable is provided, the output will be pretty-printed for readability; otherwise, it is compact. + +This writer's format is ideal for machine parsing, log aggregation, or integrating with external logging systems, especially useful for production environments or when logs need to be consumed by other tools. + +```ts +import { JSONLogWriter, Logger } from '@graphql-hive/logger'; + +const log = new Logger({ writers: [new JSONLogWriter()] }); + +log.info({ my: 'attrs' }, 'Hello World!'); +``` + +Outputs: + + +```sh +{"my":"attrs","level":"info","msg":"Hello World!","timestamp":"2025-04-10T14:00:00.000Z"} +``` + + +Or pretty printed: + + +```sh +$ LOG_JSON_PRETTY=1 node example.js + +{ + "my": "attrs", + "level": "info", + "msg": "Hello World!", + "timestamp": "2025-04-10T14:00:00.000Z" +} +``` + + +### Custom Writers + +You can implement custom log writers for the Hive Logger by creating a class that implements the `LogWriter` interface. This interface requires a single `write` method, which receives the log level, attributes, and message. + +Your writer can perform any action, such as sending logs to a file, external service, or custom destination. + +Writers can be synchronous (returning `void`) or asynchronous (returning a `Promise`). If your writer performs asynchronous operations (like network requests or file writes), simply return a promise from the `write` method. + +```ts +import { + Attributes, + ConsoleLogWriter, + Logger, + LogLevel, + LogWriter, +} from '@graphql-hive/logger'; + +class HTTPLogWriter implements LogWriter { + async write(level: LogLevel, attrs: Attributes, msg: string) { + await fetch('https://my-log-service.com', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ level, attrs, msg }), + }); + } +} + +const log = new Logger({ + // send logs both to the HTTP loggging service and output them to the console + writers: [new HTTPLogWriter(), new ConsoleLogWriter()], +}); + +log.info('Hello World!'); + +await log.flush(); // make sure all async writes settle +``` + +#### Flushing and Non-Blocking Logging + +The logger does not block when you log asynchronously. Instead, it tracks all pending async writes internally. When you call `log.flush()` or dispose the logger when using the [Explicit Resource Management](https://github.com/tc39/proposal-explicit-resource-management), it waits for all pending writes to finish, ensuring no logs are lost on shutdown. During normal operation, logging remains fast and non-blocking, even if some writers are async. + +This design allows you to use async writers without impacting the performance of your application or blocking the main thread. diff --git a/packages/logger/src/Logger.ts b/packages/logger/src/Logger.ts index e48d8b80d..bbab9c2ce 100644 --- a/packages/logger/src/Logger.ts +++ b/packages/logger/src/Logger.ts @@ -14,6 +14,8 @@ import { } from './utils'; import { ConsoleLogWriter, JSONLogWriter, LogWriter } from './writers'; +export type { Attributes } from './utils'; + export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error'; // TODO: explain what happens when attribute keys match existing keys from the logger (like "msg") From 7e1009fef54c42e683eb3ee9a6385eb037c8d751 Mon Sep 17 00:00:00 2001 From: enisdenjo Date: Sun, 20 Apr 2025 20:06:56 +0200 Subject: [PATCH 104/157] aggregate writes --- packages/logger/README.md | 45 ++++++++++++++++++++++++++++ packages/logger/src/Logger.ts | 38 +++++++++++++++-------- packages/logger/tests/Logger.test.ts | 33 ++++++++++++++++++++ 3 files changed, 104 insertions(+), 12 deletions(-) diff --git a/packages/logger/README.md b/packages/logger/README.md index ddc948d07..2faaaaa37 100644 --- a/packages/logger/README.md +++ b/packages/logger/README.md @@ -270,3 +270,48 @@ await log.flush(); // make sure all async writes settle The logger does not block when you log asynchronously. Instead, it tracks all pending async writes internally. When you call `log.flush()` or dispose the logger when using the [Explicit Resource Management](https://github.com/tc39/proposal-explicit-resource-management), it waits for all pending writes to finish, ensuring no logs are lost on shutdown. During normal operation, logging remains fast and non-blocking, even if some writers are async. This design allows you to use async writers without impacting the performance of your application or blocking the main thread. + +##### Handling Async Write Errors + +The Logger handles write errors for asynchronous writers by tracking all write promises. When `await log.flush()` is called (including during async disposal), it waits for all pending writes to settle. If any writes fail (i.e., their promises reject), their errors are collected and after all writes have settled, if there were any errors, an `AggregateError` is thrown containing all the individual write errors. + +```ts +import { Logger } from './Logger'; + +let i = 0; +const log = new Logger({ + writers: [ + { + async write() { + i++; + throw new Error('Write failed! #' + i); + }, + }, + ], +}); + +// no fail during logs +log.info('hello'); +log.info('world'); + +try { + await log.flush(); +} catch (e) { + // flush will fail with each individually failed writes + console.error(e); +} +``` + +Outputs: + +```sh +AggregateError: Failed to flush 2 writes + at async (/project/example.js:20:3) { + [errors]: [ + Error: Write failed! #1 + at Object.write (/project/example.js:9:15), + Error: Write failed! #2 + at Object.write (/project/example.js:9:15) + ] +} +``` diff --git a/packages/logger/src/Logger.ts b/packages/logger/src/Logger.ts index bbab9c2ce..592945532 100644 --- a/packages/logger/src/Logger.ts +++ b/packages/logger/src/Logger.ts @@ -105,23 +105,37 @@ export class Logger implements AsyncDisposable { attrs: Attributes | null | undefined, msg: string | null | undefined, ): void { - const pendingWrites = this.#writers - .map((writer) => writer.write(level, attrs, msg)) - .filter(isPromise); - - for (const pendingWrite of pendingWrites) { - this.#pendingWrites.add(pendingWrite); - pendingWrite.catch(() => { - // TODO: what to do if the async write failed? - }); - pendingWrite.finally(() => this.#pendingWrites.delete(pendingWrite)); + for (const w of this.#writers) { + const write$ = w.write(level, attrs, msg); + if (isPromise(write$)) { + this.#pendingWrites.add(write$); + write$ + .then(() => { + // we remove from pending writes only if the write was successful + this.#pendingWrites.delete(write$); + }) + .catch(() => { + // otherwise we keep in the pending write to throw on flush + }); + } } } public flush() { if (this.#pendingWrites.size) { - return Promise.all(this.#pendingWrites).then(() => { - // void + const errs: unknown[] = []; + return Promise.allSettled( + this.#pendingWrites + .values() + .map((w) => w.catch((err) => errs.push(err))), + ).then(() => { + this.#pendingWrites.clear(); + if (errs.length) { + throw new AggregateError( + errs, + `Failed to flush ${errs.length} writes`, + ); + } }); } return; diff --git a/packages/logger/tests/Logger.test.ts b/packages/logger/tests/Logger.test.ts index dca3a56e6..a68f7f527 100644 --- a/packages/logger/tests/Logger.test.ts +++ b/packages/logger/tests/Logger.test.ts @@ -314,6 +314,39 @@ it('should wait for async writers on flush', async () => { `); }); +it('should handle async write errors on flush', async () => { + let i = 0; + const log = new Logger({ + writers: [ + { + async write() { + i++; + throw new Error('Write failed! #' + i); + }, + }, + ], + }); + + // no fail + log.info('hello'); + log.info('world'); + + try { + await log.flush(); + throw new Error('should not have reached here'); + } catch (e) { + expect(e).toMatchInlineSnapshot( + `[AggregateError: Failed to flush 2 writes]`, + ); + expect((e as AggregateError).errors).toMatchInlineSnapshot(` + [ + [Error: Write failed! #1], + [Error: Write failed! #2], + ] + `); + } +}); + it('should wait for async writers on async dispose', async () => { const logs: any[] = []; From ac93cd94d5c598340052aad0878882b093722292 Mon Sep 17 00:00:00 2001 From: enisdenjo Date: Sun, 20 Apr 2025 20:25:49 +0200 Subject: [PATCH 105/157] message formatting --- packages/logger/README.md | 18 ++++++++++++++++++ packages/logger/tests/Logger.test.ts | 5 +++++ 2 files changed, 23 insertions(+) diff --git a/packages/logger/README.md b/packages/logger/README.md index 2faaaaa37..74fa41e6e 100644 --- a/packages/logger/README.md +++ b/packages/logger/README.md @@ -72,6 +72,24 @@ $ LOG_JSON=1 node example.js ``` +## Message Formatting + +The Hive Logger uses the [`quick-format-unescaped` library](https://github.com/pinojs/quick-format-unescaped) to format log messages that include interpolation (e.g., placeholders like %s, %d, etc.). + +```ts +import { Logger } from '@graphql-hive/logger'; + +const log = new Logger(); + +log.info('hello %s %j %d %o', 'world', { obj: true }, 4, { another: 'obj' }); +``` + +Outputs: + +```sh +2025-04-10T14:00:00.000Z INF hello world {"obj":true} 4 {"another":"obj"} +``` + ## Logging Levels The default logger uses the `info` log level which will make sure to log only `info`+ logs. Available log levels are: diff --git a/packages/logger/tests/Logger.test.ts b/packages/logger/tests/Logger.test.ts index a68f7f527..e5bb907e3 100644 --- a/packages/logger/tests/Logger.test.ts +++ b/packages/logger/tests/Logger.test.ts @@ -456,6 +456,7 @@ it('should format string', () => { log.info('%o hello %s', { worldly: 1 }, 'world'); log.info({ these: { are: 'attrs' } }, '%o hello %s', { worldly: 1 }, 'world'); + log.info('hello %s %j %d %o', 'world', { obj: true }, 4, { another: 'obj' }); expect(writer.logs).toMatchInlineSnapshot(` [ @@ -472,6 +473,10 @@ it('should format string', () => { "level": "info", "msg": "{"worldly":1} hello world", }, + { + "level": "info", + "msg": "hello world {"obj":true} 4 {"another":"obj"}", + }, ] `); }); From 9dd5b34cdb272274449b13b47565207350e0b6f3 Mon Sep 17 00:00:00 2001 From: enisdenjo Date: Sun, 20 Apr 2025 20:40:34 +0200 Subject: [PATCH 106/157] child loggers --- packages/logger/README.md | 48 ++++++++++++++++++++++++++++++++++ packages/logger/src/Logger.ts | 2 -- packages/logger/src/writers.ts | 2 -- 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/packages/logger/README.md b/packages/logger/README.md index 74fa41e6e..704d425a5 100644 --- a/packages/logger/README.md +++ b/packages/logger/README.md @@ -151,6 +151,52 @@ Outputs the following to the console: ``` +## Child Loggers + +Child loggers in Hive Logger allow you to create new logger instances that inherit configuration (such as log level, writers, and attributes) from their parent logger. This is useful for associating contextual information (like request IDs or component names) with all logs from a specific part of your application. + +When you create a child logger using the child method, you can: + +- Add a prefix to all log messages from the child logger. +- Add attributes that will be included in every log entry from the child logger. +- Inherit the log level and writers from the parent logger, unless explicitly changed on the child. + +This makes it easy to organize and structure logs in complex applications, ensuring that related logs carry consistent context. + +> [!IMPORTANT] +> In a child logger, attributes provided in individual log calls will overwrite any attributes inherited from the parent logger if they share the same keys. This allows you to override or add context-specific attributes for each log entry. + +For example, running this: + +```ts +import { Logger } from '@graphql-hive/logger'; + +const log = new Logger(); + +const child = log.child({ requestId: '123-456' }, '[child] '); + +child.info('Hello World!'); +child.info({ requestId: 'overwritten attribute' }); + +const nestedChild = child.child({ traceId: '789-012' }, '[nestedChild] '); + +nestedChild.info('Hello Deep Down!'); +``` + +Will output: + + +```sh +2025-04-10T14:00:00.000Z INF [child] Hello World! + requestId: "123-456" +2025-04-10T14:00:00.000Z INF [child] + requestId: "overwritten attribute" +2025-04-20T18:39:30.291Z INF [child] [nestedChild] Hello Deep Down! + requestId: "123-456" + traceId: "789-012" +``` + + ## Writers Logger writers are responsible for handling how and where log messages are output. In Hive Logger, writers are pluggable components that receive structured log data and determine its final destination and format. This allows you to easily customize logging behavior, such as printing logs to the console, writing them as JSON, storing them in memory for testing, or sending them to external systems. @@ -211,6 +257,8 @@ INF Hello World! Built-in log writer that outputs each log entry as a structured JSON object. When used, it prints logs to the console in JSON format, including all provided attributes, the log level, message, and a timestamp. +In the JSONLogWriter implementation, any attributes you provide with the keys `msg`, `timestamp`, or `level` will be overwritten in the final log output. This is because the writer explicitly sets these fields when constructing the log object. If you include these keys in your attributes, their values will be replaced by the logger's own values in the JSON output. + If the `LOG_JSON_PRETTY=1` environment variable is provided, the output will be pretty-printed for readability; otherwise, it is compact. This writer's format is ideal for machine parsing, log aggregation, or integrating with external logging systems, especially useful for production environments or when logs need to be consumed by other tools. diff --git a/packages/logger/src/Logger.ts b/packages/logger/src/Logger.ts index 592945532..aea48c7de 100644 --- a/packages/logger/src/Logger.ts +++ b/packages/logger/src/Logger.ts @@ -18,8 +18,6 @@ export type { Attributes } from './utils'; export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error'; -// TODO: explain what happens when attribute keys match existing keys from the logger (like "msg") - export interface LoggerOptions { /** * The minimum log level to log. diff --git a/packages/logger/src/writers.ts b/packages/logger/src/writers.ts index 21e2f8bde..16ba51778 100644 --- a/packages/logger/src/writers.ts +++ b/packages/logger/src/writers.ts @@ -6,8 +6,6 @@ export function jsonStringify(val: unknown, pretty?: boolean): string { return fastSafeStringify(val, undefined, pretty ? 2 : undefined); } -// TODO: decide whether logwriters need to have a flush method too or not (the logger will flush any pending writes) - export interface LogWriter { write( level: LogLevel, From 585bfa216f8a7c0280ec3f7f2d26878655123d4a Mon Sep 17 00:00:00 2001 From: enisdenjo Date: Sun, 20 Apr 2025 20:41:28 +0200 Subject: [PATCH 107/157] default and stuff --- packages/logger/README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/logger/README.md b/packages/logger/README.md index 704d425a5..a31c424d9 100644 --- a/packages/logger/README.md +++ b/packages/logger/README.md @@ -253,7 +253,10 @@ INF Hello World! ``` -### `JSONLogWriter` (default when `LOG_JSON=1`) +### `JSONLogWriter` + +> [!NOTE] +> Will be used then the `LOG_JSON=1` environment variable is provided. Built-in log writer that outputs each log entry as a structured JSON object. When used, it prints logs to the console in JSON format, including all provided attributes, the log level, message, and a timestamp. From 74894a4bf236b17d500bbd02131d40bb900c8705 Mon Sep 17 00:00:00 2001 From: enisdenjo Date: Sun, 20 Apr 2025 20:54:46 +0200 Subject: [PATCH 108/157] logger for request --- packages/logger/src/request.ts | 55 ++++---------------- packages/runtime/src/plugins/useRequestId.ts | 9 +++- 2 files changed, 18 insertions(+), 46 deletions(-) diff --git a/packages/logger/src/request.ts b/packages/logger/src/request.ts index 5e80a8326..af83b6357 100644 --- a/packages/logger/src/request.ts +++ b/packages/logger/src/request.ts @@ -1,55 +1,20 @@ import { Logger } from './Logger'; -// TODO: write tests - export const requestIdByRequest = new WeakMap(); -/** The getter function that extracts the requestID from the {@link request} or creates a new one if none-exist. */ -export type GetRequestID = (request: Request) => string; +const loggerByRequest = new WeakMap(); /** - * Creates a child {@link Logger} under the {@link log given logger} for the {@link request}. - * - * Request's ID will be stored in the {@link requestIdByRequest} weak map; meaning, all - * subsequent calls to this function with the same {@link request} will return the same ID. - * - * The {@link getId} argument will be used to create a new ID if the {@link request} does not - * have one. The convention is to the `X-Request-ID` header or create a new ID which is an - * UUID v4. + * Gets the {@link Logger} of for the {@link request}. * - * On the other hand, if the {@link getId} argument is omitted, the {@link requestIdByRequest} weak - * map will be looked up, and if there is no ID stored for the {@link request} - the function - * will not attempt to create a new ID and will just return the same {@link log logger}. - * - * The request ID will be added to the logger attributes under the `requestId` key and - * will be logged in every subsequent log. + * If the request does not have a logger, the provided {@link log} + * will be associated to the {@link request} and returned. */ -export function loggerForRequest(log: Logger, request: Request): Logger; -export function loggerForRequest( - log: Logger, - request: Request, - getId: GetRequestID, -): Logger; -export function loggerForRequest( - log: Logger, - request: Request, - getId?: GetRequestID, -): Logger { - let requestId = requestIdByRequest.get(request); - if (!requestId) { - if (getId === undefined) { - return log; - } - requestId = getId(request); - requestIdByRequest.set(request, requestId); - } - if ( - log.attrs && - 'requestId' in log.attrs && - log.attrs['requestId'] === requestId - ) { - // this logger is already a child that contains this request id, no need to create a new one - return log; +export function loggerForRequest(log: Logger, request: Request): Logger { + const reqLog = loggerByRequest.get(request); + if (reqLog) { + return reqLog; } - return log.child({ requestId }); + loggerByRequest.set(request, log); + return log; } diff --git a/packages/runtime/src/plugins/useRequestId.ts b/packages/runtime/src/plugins/useRequestId.ts index 017dd2dc5..57ca584c2 100644 --- a/packages/runtime/src/plugins/useRequestId.ts +++ b/packages/runtime/src/plugins/useRequestId.ts @@ -54,7 +54,14 @@ export function useRequestId>( }, onContextBuilding({ context, extendContext }) { // the request ID wont always be available because there's no request in websockets - const log = loggerForRequest(context.log, context.request); + const requestId = requestIdByRequest.get(context.request); + let log = context.log; + if (requestId) { + log = loggerForRequest( + context.log.child({ requestId }), + context.request, + ); + } extendContext( // @ts-expect-error TODO: typescript is acting up here { From 784cbdd2b499a35d05cd4ca640b51e52194399ed Mon Sep 17 00:00:00 2001 From: enisdenjo Date: Sun, 20 Apr 2025 21:14:41 +0200 Subject: [PATCH 109/157] color brackets --- packages/logger/src/writers.ts | 6 +++ .../tests/__snapshots__/writers.test.ts.snap | 45 ++++++++++++++++--- packages/logger/tests/writers.test.ts | 10 ++--- 3 files changed, 51 insertions(+), 10 deletions(-) diff --git a/packages/logger/src/writers.ts b/packages/logger/src/writers.ts index 16ba51778..5d17afa69 100644 --- a/packages/logger/src/writers.ts +++ b/packages/logger/src/writers.ts @@ -134,6 +134,12 @@ export class ConsoleLogWriter implements LogWriter { // remove the ending comma formattedLine = formattedLine.replace(/,$/, ''); + // color the opening and closing brackets + formattedLine = formattedLine.replace( + /(\[|\{|\]|\})$/, + this.color('key', '$1'), + ); + log += formattedLine + '\n'; } diff --git a/packages/logger/tests/__snapshots__/writers.test.ts.snap b/packages/logger/tests/__snapshots__/writers.test.ts.snap index 2fe56a003..9262158b0 100644 --- a/packages/logger/tests/__snapshots__/writers.test.ts.snap +++ b/packages/logger/tests/__snapshots__/writers.test.ts.snap @@ -3,15 +3,50 @@ exports[`ConsoleLogWriter should color levels and keys 1`] = ` [ "TRC hi - hello: "world"", + hello: { + dear: "world" + try: [ + "num" + 1 + 2 + ] + }", "DBG hi - hello: "world"", + hello: { + dear: "world" + try: [ + "num" + 1 + 2 + ] + }", "INF hi - hello: "world"", + hello: { + dear: "world" + try: [ + "num" + 1 + 2 + ] + }", "WRN hi - hello: "world"", + hello: { + dear: "world" + try: [ + "num" + 1 + 2 + ] + }", "ERR hi - hello: "world"", + hello: { + dear: "world" + try: [ + "num" + 1 + 2 + ] + }", ] `; diff --git a/packages/logger/tests/writers.test.ts b/packages/logger/tests/writers.test.ts index de6f60c59..3b1448727 100644 --- a/packages/logger/tests/writers.test.ts +++ b/packages/logger/tests/writers.test.ts @@ -60,11 +60,11 @@ describe('ConsoleLogWriter', () => { it('should color levels and keys', () => { const [log, logs] = createTConsoleLogger({ noColor: false }); - log.trace({ hello: 'world' }, 'hi'); - log.debug({ hello: 'world' }, 'hi'); - log.info({ hello: 'world' }, 'hi'); - log.warn({ hello: 'world' }, 'hi'); - log.error({ hello: 'world' }, 'hi'); + log.trace({ hello: { dear: 'world', try: ['num', 1, 2] } }, 'hi'); + log.debug({ hello: { dear: 'world', try: ['num', 1, 2] } }, 'hi'); + log.info({ hello: { dear: 'world', try: ['num', 1, 2] } }, 'hi'); + log.warn({ hello: { dear: 'world', try: ['num', 1, 2] } }, 'hi'); + log.error({ hello: { dear: 'world', try: ['num', 1, 2] } }, 'hi'); expect(logs).toMatchSnapshot(); }); From 3f4f92a76db3db9959909c7fc29a262b641ed838 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Mon, 21 Apr 2025 14:46:03 +0200 Subject: [PATCH 110/157] dynamic level --- packages/logger/README.md | 76 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/packages/logger/README.md b/packages/logger/README.md index a31c424d9..2f065d755 100644 --- a/packages/logger/README.md +++ b/packages/logger/README.md @@ -100,7 +100,9 @@ The default logger uses the `info` log level which will make sure to log only `i - `warn` - `error` -You can change the loggers logging level on creation or dynamically. +### Change Logging Level on Creation + +When creating an instance of the logger, you can configure the logging level by configuring the `level` option. Like this: ```ts import { Logger } from '@graphql-hive/logger'; @@ -120,6 +122,35 @@ log.debug('Hello world!'); const child = log.child('[prefix] '); +child.debug('Child loggers inherit the parent log level'); +``` + +Outputs the following to the console: + + +```sh +2025-04-10T14:00:00.000Z DBG Hello world! +2025-04-10T14:00:00.000Z DBG [prefix] Child loggers inherit the parent log level +``` + + +### Change Logging Level Dynamically + +Alternatively, you can change the logging level dynamically during runtime. There's two possible ways of doing that. + +#### Using `log.setLevel(level: LogLevel)` + +One way of doing it is by using the log's `setLevel` method. + +```ts +import { Logger } from '@graphql-hive/logger'; + +const log = new Logger({ level: 'debug' }); + +log.debug('Hello world!'); + +const child = log.child('[prefix] '); + child.debug('Child loggers inherit the parent log level'); log.setLevel('trace'); @@ -151,6 +182,49 @@ Outputs the following to the console: ``` +#### Using `LoggerOptions.level` Function + +Another way of doing it is to pass a function to the `level` option when creating a logger. + +```ts +import { Logger } from '@graphql-hive/logger'; + +let isDebug = false; + +const log = new Logger({ + level: () => { + if (isDebug) { + return 'debug'; + } + return 'info'; + }, +}); + +log.debug('isDebug is false, so this wont be logged'); + +log.info('Hello world!'); + +const child = log.child('[scoped] '); + +child.debug( + 'Child loggers inherit the parent log level function, so this wont be logged either', +); + +// enable debug mode +isDebug = true; + +child.debug('Now debug is enabled and logged'); +``` + +Outputs the following: + + +```sh +2025-04-10T14:00:00.000Z INF Hello world! +2025-04-10T14:00:00.000Z DBG [scoped] Now debug is enabled and logged +``` + + ## Child Loggers Child loggers in Hive Logger allow you to create new logger instances that inherit configuration (such as log level, writers, and attributes) from their parent logger. This is useful for associating contextual information (like request IDs or component names) with all logs from a specific part of your application. From 1d55efad5ed85ab082fb3652af2091d63499fd42 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Mon, 21 Apr 2025 14:49:02 +0200 Subject: [PATCH 111/157] exprsmng --- packages/logger/README.md | 39 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/logger/README.md b/packages/logger/README.md index 2f065d755..3c67c79c9 100644 --- a/packages/logger/README.md +++ b/packages/logger/README.md @@ -410,10 +410,47 @@ await log.flush(); // make sure all async writes settle #### Flushing and Non-Blocking Logging -The logger does not block when you log asynchronously. Instead, it tracks all pending async writes internally. When you call `log.flush()` or dispose the logger when using the [Explicit Resource Management](https://github.com/tc39/proposal-explicit-resource-management), it waits for all pending writes to finish, ensuring no logs are lost on shutdown. During normal operation, logging remains fast and non-blocking, even if some writers are async. +The logger does not block when you log asynchronously. Instead, it tracks all pending async writes internally. When you call `log.flush()` it waits for all pending writes to finish, ensuring no logs are lost on shutdown. During normal operation, logging remains fast and non-blocking, even if some writers are async. This design allows you to use async writers without impacting the performance of your application or blocking the main thread. +#### Explicit Resource Management + +The Hive Logger also supports [Explicit Resource Management](https://github.com/tc39/proposal-explicit-resource-management). This allows you to ensure that all pending asynchronous log writes are properly flushed before your application exits or when the logger is no longer needed. + +You can use the logger with `await using` (in environments that support it) to wait for all log operations to complete. This is especially useful in serverless or short-lived environments where you want to guarantee that no logs are lost due to unfinished asynchronous operations. + +```ts +import { + Attributes, + ConsoleLogWriter, + Logger, + LogLevel, + LogWriter, +} from '@graphql-hive/logger'; + +class HTTPLogWriter implements LogWriter { + async write(level: LogLevel, attrs: Attributes, msg: string) { + await fetch('https://my-log-service.com', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ level, attrs, msg }), + }); + } +} + +{ + await using log = new Logger({ + // send logs both to the HTTP loggging service and output them to the console + writers: [new HTTPLogWriter(), new ConsoleLogWriter()], + }); + + log.info('Hello World!'); +} + +// logger went out of scope and all of the logs have been flushed +``` + ##### Handling Async Write Errors The Logger handles write errors for asynchronous writers by tracking all write promises. When `await log.flush()` is called (including during async disposal), it waits for all pending writes to settle. If any writes fail (i.e., their promises reject), their errors are collected and after all writes have settled, if there were any errors, an `AggregateError` is thrown containing all the individual write errors. From 34afde52cdf8f33e76cd0b6cc287843072b566ba Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Mon, 21 Apr 2025 14:52:06 +0200 Subject: [PATCH 112/157] otel log --- packages/gateway/src/config.ts | 2 +- packages/plugins/opentelemetry/src/plugin.ts | 31 +++++++++---------- .../plugins/opentelemetry/tests/yoga.spec.ts | 2 ++ 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/packages/gateway/src/config.ts b/packages/gateway/src/config.ts index caaf6258e..fcbda7647 100644 --- a/packages/gateway/src/config.ts +++ b/packages/gateway/src/config.ts @@ -126,7 +126,7 @@ export async function getBuiltinPluginsFromConfig( const { useOpenTelemetry } = await import( '@graphql-mesh/plugin-opentelemetry' ); - plugins.push(useOpenTelemetry(config.openTelemetry)); + plugins.push(useOpenTelemetry({ ...config.openTelemetry, log: ctx.log })); } if (config.rateLimiting) { diff --git a/packages/plugins/opentelemetry/src/plugin.ts b/packages/plugins/opentelemetry/src/plugin.ts index 799883c3f..aafbc4ce2 100644 --- a/packages/plugins/opentelemetry/src/plugin.ts +++ b/packages/plugins/opentelemetry/src/plugin.ts @@ -1,7 +1,7 @@ import { getRetryInfo, isRetryExecutionRequest, - type GatewayConfigContext, + Logger, type GatewayPlugin, } from '@graphql-hive/gateway-runtime'; import { getHeadersObj } from '@graphql-mesh/utils'; @@ -37,7 +37,6 @@ import { WebTracerProvider, } from '@opentelemetry/sdk-trace-web'; import { unfakePromise } from '@whatwg-node/promise-helpers'; -import { YogaLogger } from 'graphql-yoga'; import { ATTR_SERVICE_VERSION, SEMRESATTRS_SERVICE_NAME } from './attributes'; import { getContextManager, OtelContextStack } from './context'; import { @@ -278,7 +277,7 @@ export type OpenTelemetryPlugin = export function useOpenTelemetry( options: OpenTelemetryGatewayPluginOptions & { - logger?: GatewayConfigContext['logger']; + log: Logger; }, ): OpenTelemetryPlugin { const inheritContext = options.inheritContext ?? true; @@ -312,14 +311,14 @@ export function useOpenTelemetry( return specificState?.current ?? ROOT_CONTEXT; } - const yogaLogger = createDeferred(); - let pluginLogger = options.logger + const logger = createDeferred(); + let pluginLogger = options.log ? fakePromise( - options.logger.child({ + options.log.child({ plugin: 'OpenTelemetry', }), ) - : yogaLogger.promise; + : logger.promise; function init(): Promise { if ('initializeNodeSDK' in options && options.initializeNodeSDK === false) { @@ -394,9 +393,9 @@ export function useOpenTelemetry( }), ); preparation$ = fakePromise(); - return pluginLogger.then((logger) => { - pluginLogger = fakePromise(logger); - logger.debug( + return pluginLogger.then((log) => { + pluginLogger = fakePromise(log); + log.debug( `context manager is ${useContextManager ? 'enabled' : 'disabled'}`, ); if (!useContextManager) { @@ -411,15 +410,15 @@ export function useOpenTelemetry( diag.setLogger( { error: (message, ...args) => - logger.error('[otel-diag] ' + message, ...args), + log.error('[otel-diag] ' + message, ...args), warn: (message, ...args) => - logger.warn('[otel-diag] ' + message, ...args), + log.warn('[otel-diag] ' + message, ...args), info: (message, ...args) => - logger.info('[otel-diag] ' + message, ...args), + log.info('[otel-diag] ' + message, ...args), debug: (message, ...args) => - logger.debug('[otel-diag] ' + message, ...args), + log.debug('[otel-diag] ' + message, ...args), verbose: (message, ...args) => - logger.debug('[otel-diag] ' + message, ...args), + log.debug('[otel-diag] ' + message, ...args), }, options.diagLevel ?? DiagLogLevel.VERBOSE, ); @@ -756,7 +755,7 @@ export function useOpenTelemetry( onYogaInit({ yoga }) { yogaVersion.resolve(yoga.version); - yogaLogger.resolve(yoga.logger); + logger.resolve(options.log); }, onEnveloped({ state, extendContext }) { diff --git a/packages/plugins/opentelemetry/tests/yoga.spec.ts b/packages/plugins/opentelemetry/tests/yoga.spec.ts index 8e17c5c15..0dfe37b4f 100644 --- a/packages/plugins/opentelemetry/tests/yoga.spec.ts +++ b/packages/plugins/opentelemetry/tests/yoga.spec.ts @@ -1,3 +1,4 @@ +import { Logger } from '@graphql-hive/logger'; import { useOpenTelemetry } from '@graphql-mesh/plugin-opentelemetry'; import { createSchema, createYoga, Plugin as YogaPlugin } from 'graphql-yoga'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -34,6 +35,7 @@ describe('useOpenTelemetry', () => { const otelPlugin = useOpenTelemetry({ initializeNodeSDK: false, contextManager, + log: new Logger({ level: false }), }); const yoga = createYoga({ From 50d36936113740a33d348f0c1f26d2b87408d0de Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Mon, 21 Apr 2025 14:54:25 +0200 Subject: [PATCH 113/157] disable logging example --- packages/logger/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/logger/README.md b/packages/logger/README.md index 3c67c79c9..3bce5844d 100644 --- a/packages/logger/README.md +++ b/packages/logger/README.md @@ -94,6 +94,7 @@ Outputs: The default logger uses the `info` log level which will make sure to log only `info`+ logs. Available log levels are: +- false (disables logging altogether) - `trace` - `debug` - `info` _default_ From e018254f2643a5764abfb3a3464f5d81a78a0685 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Mon, 21 Apr 2025 14:59:08 +0200 Subject: [PATCH 114/157] arrayfrom --- packages/logger/src/Logger.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/logger/src/Logger.ts b/packages/logger/src/Logger.ts index aea48c7de..6f0255f65 100644 --- a/packages/logger/src/Logger.ts +++ b/packages/logger/src/Logger.ts @@ -123,9 +123,9 @@ export class Logger implements AsyncDisposable { if (this.#pendingWrites.size) { const errs: unknown[] = []; return Promise.allSettled( - this.#pendingWrites - .values() - .map((w) => w.catch((err) => errs.push(err))), + Array.from(this.#pendingWrites).map((w) => + w.catch((err) => errs.push(err)), + ), ).then(() => { this.#pendingWrites.clear(); if (errs.length) { From 07dc9dab05e71ffee6ed26090a7f02ebaac19539 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Mon, 21 Apr 2025 15:00:00 +0200 Subject: [PATCH 115/157] h++ --- packages/logger/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/logger/README.md b/packages/logger/README.md index 3bce5844d..6c90040c2 100644 --- a/packages/logger/README.md +++ b/packages/logger/README.md @@ -415,7 +415,7 @@ The logger does not block when you log asynchronously. Instead, it tracks all pe This design allows you to use async writers without impacting the performance of your application or blocking the main thread. -#### Explicit Resource Management +##### Explicit Resource Management The Hive Logger also supports [Explicit Resource Management](https://github.com/tc39/proposal-explicit-resource-management). This allows you to ensure that all pending asynchronous log writes are properly flushed before your application exits or when the logger is no longer needed. From 4e0f594da2907dfab5ebef5a4c7aa81e5e6a1427 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Mon, 21 Apr 2025 15:11:03 +0200 Subject: [PATCH 116/157] logging methods and args --- packages/logger/README.md | 67 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/packages/logger/README.md b/packages/logger/README.md index 6c90040c2..09f37640a 100644 --- a/packages/logger/README.md +++ b/packages/logger/README.md @@ -72,6 +72,73 @@ $ LOG_JSON=1 node example.js ``` +## Logging Methods and Their Arguments + +Hive Logger provides convenient methods for each log level: `trace`, `debug`, `info`, `warn`, and `error`. + +All logging methods support flexible argument patterns for structured and formatted logging: + +### No Arguments + +Logs an empty message at the specified level. + +```ts +log.debug(); +``` + +```sh +2025-04-10T14:00:00.000Z DBG +``` + +### Attributes Only + +Logs structured attributes without a message. + +```ts +log.info({ hello: 'world' }); +``` + + +```sh +2025-04-10T14:00:00.000Z INF + hello: "world" +``` + + +### Message with Interpolation + +Logs a formatted message, similar to printf-style formatting. Read more about it in the [Message Formatting section](#message-formatting). + +```ts +log.warn('Hello %s!', 'World'); +``` + + +```sh +2025-04-10T14:00:00.000Z WRN Hello World! +``` + + +### Attributes and Message (with interpolation) + +Logs structured attributes and a formatted message. The attributes can be anything object-like, including classes. + +```ts +const err = new Error('Something went wrong!'); +log.error(err, 'Problem occurred at %s', new Date()); +``` + + +```sh +2025-04-10T14:00:00.000Z ERR Problem occurred at Thu Apr 10 2025 14:00:00 GMT+0200 (Central European Summer Time) + stack: "Error: Something went wrong! + at (/projects/example.ts:2:1)" + message: "Something went wrong!" + name: "Error" + class: "Error" +``` + + ## Message Formatting The Hive Logger uses the [`quick-format-unescaped` library](https://github.com/pinojs/quick-format-unescaped) to format log messages that include interpolation (e.g., placeholders like %s, %d, etc.). From 9a07fcc6f65dbd9bd8495bef65b05492030225a6 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Mon, 21 Apr 2025 15:16:25 +0200 Subject: [PATCH 117/157] no test writers on bun --- packages/logger/tests/writers.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/logger/tests/writers.test.ts b/packages/logger/tests/writers.test.ts index 3b1448727..7aa3a4613 100644 --- a/packages/logger/tests/writers.test.ts +++ b/packages/logger/tests/writers.test.ts @@ -3,7 +3,10 @@ import { Logger } from '../src/Logger'; import { ConsoleLogWriter, ConsoleLogWriterOptions } from '../src/writers'; import { stableError } from './utils'; -describe('ConsoleLogWriter', () => { +describe.skipIf( + // bun is serialising the snapshots differently. the object keys are out of order... + globalThis.Bun, +)('ConsoleLogWriter', () => { function createTConsoleLogger(opts?: Partial) { const logs: string[] = []; const writer = new ConsoleLogWriter({ From b35291650e9dd68ba798c4eb6decbf57bdca1f72 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Mon, 21 Apr 2025 15:22:10 +0200 Subject: [PATCH 118/157] .js --- packages/logger/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/logger/README.md b/packages/logger/README.md index 09f37640a..6ee9d9c5d 100644 --- a/packages/logger/README.md +++ b/packages/logger/README.md @@ -132,7 +132,7 @@ log.error(err, 'Problem occurred at %s', new Date()); ```sh 2025-04-10T14:00:00.000Z ERR Problem occurred at Thu Apr 10 2025 14:00:00 GMT+0200 (Central European Summer Time) stack: "Error: Something went wrong! - at (/projects/example.ts:2:1)" + at (/projects/example.js:2:1)" message: "Something went wrong!" name: "Error" class: "Error" From 4014e63a94ec9f9f3c27adcf64875dad5a38ef17 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Mon, 21 Apr 2025 15:27:03 +0200 Subject: [PATCH 119/157] advanced serialisation --- packages/logger/README.md | 70 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/packages/logger/README.md b/packages/logger/README.md index 6ee9d9c5d..112eb0bb2 100644 --- a/packages/logger/README.md +++ b/packages/logger/README.md @@ -563,3 +563,73 @@ AggregateError: Failed to flush 2 writes ] } ``` + +## Advanced Serialization of Attributes + +Hive Logger uses advanced serialization to ensure that all attributes are logged safely and readably, even when they contain complex or circular data structures. This means you can log rich, nested objects or errors as attributes without worrying about serialization failures or unreadable logs. + +For example, the logger will serialize the error object, including its message and stack, in a safe and readable way. This advanced serialization is applied automatically to all attributes passed to log methods, child loggers, and writers. + +```ts +import { Logger } from '@graphql-hive/logger'; + +const log = new Logger(); + +class DatabaseError extends Error { + constructor(message: string) { + super(message); + this.name = 'DatabaseError'; + } +} +const dbErr = new DatabaseError('Connection failed'); +const userErr = new Error('Updating user failed', { cause: dbErr }); +const errs = new AggregateError([dbErr, userErr], 'Failed to update user'); + +log.error(errs); +``` + + +```sh +2025-04-10T14:00:00.000Z ERR + stack: "AggregateError: Failed to update user + at (/project/example.js:13:14) + at ModuleJob.run (node:internal/modules/esm/module_job:274:25) + at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:644:26) + at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:98:5)" + message: "Failed to update user" + errors: [ + { + stack: "DatabaseError: Connection failed + at (/project/example.js:11:15) + at ModuleJob.run (node:internal/modules/esm/module_job:274:25) + at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:644:26) + at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:98:5)" + message: "Database connection failed" + name: "DatabaseError" + class: "DatabaseError" + } + { + stack: "Error: Updating user failed + at (/project/example.js:12:17) + at ModuleJob.run (node:internal/modules/esm/module_job:274:25) + at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:644:26) + at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:98:5)" + message: "Updating user failed" + cause: { + stack: "DatabaseError: Connection failed + at (/project/example.js:11:15) + at ModuleJob.run (node:internal/modules/esm/module_job:274:25) + at async onImport.tracePromise.__proto__ (node:internal/modules/esm/loader:644:26) + at async asyncRunEntryPointWithESMLoader (node:internal/modules/run_main:98:5)" + message: "Database connection failed" + name: "DatabaseError" + class: "DatabaseError" + } + name: "Error" + class: "Error" + } + ] + name: "AggregateError" + class: "AggregateError" +``` + From 3add1d0fd1310ee4f3ce1c45227e678893b1cfdf Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Mon, 21 Apr 2025 16:04:47 +0200 Subject: [PATCH 120/157] lazy args --- packages/logger/README.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/logger/README.md b/packages/logger/README.md index 112eb0bb2..090ac7c70 100644 --- a/packages/logger/README.md +++ b/packages/logger/README.md @@ -168,6 +168,28 @@ The default logger uses the `info` log level which will make sure to log only `i - `warn` - `error` +### Lazy Arguments and Performance + +Hive Logger supports "lazy" attributes for log methods. If you pass a function as the attributes argument, it will only be evaluated if the log level is enabled and the log will actually be written. This avoids unnecessary computation for expensive attributes when the log would be ignored due to the current log level. + +```ts +import { Logger } from '@graphql-hive/logger'; + +const log = new Logger({ level: 'info' }); + +log.debug( + // This function will NOT be called, since 'debug' is below the current log level. + () => ({ expensive: computeExpensiveValue() }), + 'This will not be logged', +); + +log.info( + // This function WILL be called, since 'info' log level is set. + () => ({ expensive: computeExpensiveValue() }), + 'This will be logged', +); +``` + ### Change Logging Level on Creation When creating an instance of the logger, you can configure the logging level by configuring the `level` option. Like this: From 6451a4c09254b831665e98227777c8bc1729d6f3 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Mon, 21 Apr 2025 17:01:39 +0200 Subject: [PATCH 121/157] rename move --- packages/logger/src/LegacyLogger.ts | 2 +- packages/logger/src/index.ts | 4 +- packages/logger/src/request.ts | 2 +- packages/logger/src/utils.ts | 2 +- packages/logger/src/writers/common.ts | 14 +++++ .../src/{writers.ts => writers/console.ts} | 53 ++----------------- packages/logger/src/writers/index.ts | 4 ++ packages/logger/src/writers/json.ts | 23 ++++++++ packages/logger/src/writers/memory.ts | 18 +++++++ packages/logger/tests/LegacyLogger.test.ts | 2 +- packages/logger/tests/Logger.test.ts | 2 +- packages/logger/tests/writers.test.ts | 2 +- packages/runtime/src/createGatewayRuntime.ts | 2 +- 13 files changed, 71 insertions(+), 59 deletions(-) create mode 100644 packages/logger/src/writers/common.ts rename packages/logger/src/{writers.ts => writers/console.ts} (73%) create mode 100644 packages/logger/src/writers/index.ts create mode 100644 packages/logger/src/writers/json.ts create mode 100644 packages/logger/src/writers/memory.ts diff --git a/packages/logger/src/LegacyLogger.ts b/packages/logger/src/LegacyLogger.ts index e02abaacc..ed25e8948 100644 --- a/packages/logger/src/LegacyLogger.ts +++ b/packages/logger/src/LegacyLogger.ts @@ -1,4 +1,4 @@ -import { Logger, LogLevel } from './Logger'; +import { Logger, LogLevel } from './logger'; import { shouldLog } from './utils'; // type comes from "@graphql-mesh/types" package, we're copying them over just to avoid including the whole package diff --git a/packages/logger/src/index.ts b/packages/logger/src/index.ts index d864c7e98..352b76440 100644 --- a/packages/logger/src/index.ts +++ b/packages/logger/src/index.ts @@ -1,4 +1,4 @@ -export * from './Logger'; +export * from './logger'; export * from './writers'; /** @deprecated Please migrate to using the './Logger' instead. */ -export * from './LegacyLogger'; +export * from './legacyLogger'; diff --git a/packages/logger/src/request.ts b/packages/logger/src/request.ts index af83b6357..43239bf70 100644 --- a/packages/logger/src/request.ts +++ b/packages/logger/src/request.ts @@ -1,4 +1,4 @@ -import { Logger } from './Logger'; +import { Logger } from './logger'; export const requestIdByRequest = new WeakMap(); diff --git a/packages/logger/src/utils.ts b/packages/logger/src/utils.ts index ceff37e69..7476f400f 100644 --- a/packages/logger/src/utils.ts +++ b/packages/logger/src/utils.ts @@ -1,4 +1,4 @@ -import { LogLevel } from './Logger'; +import { LogLevel } from './logger'; export type MaybeLazy = T | (() => T); diff --git a/packages/logger/src/writers/common.ts b/packages/logger/src/writers/common.ts new file mode 100644 index 000000000..4bad07367 --- /dev/null +++ b/packages/logger/src/writers/common.ts @@ -0,0 +1,14 @@ +import fastSafeStringify from 'fast-safe-stringify'; +import { Attributes, LogLevel } from '../logger'; + +export function jsonStringify(val: unknown, pretty?: boolean): string { + return fastSafeStringify(val, undefined, pretty ? 2 : undefined); +} + +export interface LogWriter { + write( + level: LogLevel, + attrs: Attributes | null | undefined, + msg: string | null | undefined, + ): void | Promise; +} diff --git a/packages/logger/src/writers.ts b/packages/logger/src/writers/console.ts similarity index 73% rename from packages/logger/src/writers.ts rename to packages/logger/src/writers/console.ts index 5d17afa69..cec5def1f 100644 --- a/packages/logger/src/writers.ts +++ b/packages/logger/src/writers/console.ts @@ -1,33 +1,6 @@ -import fastSafeStringify from 'fast-safe-stringify'; -import { LogLevel } from './Logger'; -import { Attributes, logLevelToString, truthyEnv } from './utils'; - -export function jsonStringify(val: unknown, pretty?: boolean): string { - return fastSafeStringify(val, undefined, pretty ? 2 : undefined); -} - -export interface LogWriter { - write( - level: LogLevel, - attrs: Attributes | null | undefined, - msg: string | null | undefined, - ): void | Promise; -} - -export class MemoryLogWriter implements LogWriter { - public logs: { level: LogLevel; msg?: string; attrs?: unknown }[] = []; - write( - level: LogLevel, - attrs: Attributes | null | undefined, - msg: string | null | undefined, - ): void { - this.logs.push({ - level, - ...(msg ? { msg } : {}), - ...(attrs ? { attrs } : {}), - }); - } -} +import { LogLevel } from '../logger'; +import { Attributes, logLevelToString, truthyEnv } from '../utils'; +import { jsonStringify, LogWriter } from './common'; const asciMap = { timestamp: '\x1b[90m', // bright black @@ -149,23 +122,3 @@ export class ConsoleLogWriter implements LogWriter { return log; } } - -export class JSONLogWriter implements LogWriter { - write( - level: LogLevel, - attrs: Attributes | null | undefined, - msg: string | null | undefined, - ): void { - console.log( - jsonStringify( - { - ...attrs, - level, - ...(msg ? { msg } : {}), - timestamp: new Date().toISOString(), - }, - truthyEnv('LOG_JSON_PRETTY'), - ), - ); - } -} diff --git a/packages/logger/src/writers/index.ts b/packages/logger/src/writers/index.ts new file mode 100644 index 000000000..346b943f9 --- /dev/null +++ b/packages/logger/src/writers/index.ts @@ -0,0 +1,4 @@ +export * from './common'; +export * from './console'; +export * from './json'; +export * from './memory'; diff --git a/packages/logger/src/writers/json.ts b/packages/logger/src/writers/json.ts new file mode 100644 index 000000000..2f5cd6fa9 --- /dev/null +++ b/packages/logger/src/writers/json.ts @@ -0,0 +1,23 @@ +import { LogLevel } from '../logger'; +import { Attributes, truthyEnv } from '../utils'; +import { jsonStringify, LogWriter } from './common'; + +export class JSONLogWriter implements LogWriter { + write( + level: LogLevel, + attrs: Attributes | null | undefined, + msg: string | null | undefined, + ): void { + console.log( + jsonStringify( + { + ...attrs, + level, + ...(msg ? { msg } : {}), + timestamp: new Date().toISOString(), + }, + truthyEnv('LOG_JSON_PRETTY'), + ), + ); + } +} diff --git a/packages/logger/src/writers/memory.ts b/packages/logger/src/writers/memory.ts new file mode 100644 index 000000000..5808b2d54 --- /dev/null +++ b/packages/logger/src/writers/memory.ts @@ -0,0 +1,18 @@ +import { LogLevel } from '../logger'; +import { Attributes } from '../utils'; +import { LogWriter } from './common'; + +export class MemoryLogWriter implements LogWriter { + public logs: { level: LogLevel; msg?: string; attrs?: unknown }[] = []; + write( + level: LogLevel, + attrs: Attributes | null | undefined, + msg: string | null | undefined, + ): void { + this.logs.push({ + level, + ...(msg ? { msg } : {}), + ...(attrs ? { attrs } : {}), + }); + } +} diff --git a/packages/logger/tests/LegacyLogger.test.ts b/packages/logger/tests/LegacyLogger.test.ts index d64368c6a..574074833 100644 --- a/packages/logger/tests/LegacyLogger.test.ts +++ b/packages/logger/tests/LegacyLogger.test.ts @@ -1,7 +1,7 @@ import { LegacyLogger } from '@graphql-hive/logger'; import { Logger as MeshLogger } from '@graphql-mesh/types'; import { expect, it } from 'vitest'; -import { Logger, LoggerOptions } from '../src/Logger'; +import { Logger, LoggerOptions } from '../src/logger'; import { MemoryLogWriter } from '../src/writers'; // a type test making sure the LegacyLogger is compatible with the MeshLogger diff --git a/packages/logger/tests/Logger.test.ts b/packages/logger/tests/Logger.test.ts index e5bb907e3..18eb270bc 100644 --- a/packages/logger/tests/Logger.test.ts +++ b/packages/logger/tests/Logger.test.ts @@ -1,6 +1,6 @@ import { setTimeout } from 'node:timers/promises'; import { expect, it, vi } from 'vitest'; -import { Logger, LoggerOptions } from '../src/Logger'; +import { Logger, LoggerOptions } from '../src/logger'; import { MemoryLogWriter } from '../src/writers'; import { stableError } from './utils'; diff --git a/packages/logger/tests/writers.test.ts b/packages/logger/tests/writers.test.ts index 7aa3a4613..1869f5c9d 100644 --- a/packages/logger/tests/writers.test.ts +++ b/packages/logger/tests/writers.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { Logger } from '../src/Logger'; +import { Logger } from '../src/logger'; import { ConsoleLogWriter, ConsoleLogWriterOptions } from '../src/writers'; import { stableError } from './utils'; diff --git a/packages/runtime/src/createGatewayRuntime.ts b/packages/runtime/src/createGatewayRuntime.ts index ffbe567c0..0d2ec2188 100644 --- a/packages/runtime/src/createGatewayRuntime.ts +++ b/packages/runtime/src/createGatewayRuntime.ts @@ -9,6 +9,7 @@ import { createSchemaFetcher, createSupergraphSDLFetcher, } from '@graphql-hive/core'; +import { LegacyLogger } from '@graphql-hive/logger'; import type { OnDelegationPlanHook, OnDelegationStageExecuteHook, @@ -78,7 +79,6 @@ import { type LandingPageRenderer, type YogaServerInstance, } from 'graphql-yoga'; -import { LegacyLogger } from '../../logger/src/LegacyLogger'; import { createLoggerFromLogging } from './createLoggerFromLogging'; import { createGraphOSFetcher } from './fetchers/graphos'; import { getProxyExecutor } from './getProxyExecutor'; From a9b1c89cc1d4ee04ff721e4ee18fc4935520fb74 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Mon, 21 Apr 2025 17:08:58 +0200 Subject: [PATCH 122/157] pino writer --- packages/logger/package.json | 19 +++++++++++++++++++ packages/logger/src/writers/pino.ts | 18 ++++++++++++++++++ tsconfig.json | 3 +++ yarn.lock | 6 ++++++ 4 files changed, 46 insertions(+) create mode 100644 packages/logger/src/writers/pino.ts diff --git a/packages/logger/package.json b/packages/logger/package.json index ec6bf9490..09ff11c01 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -39,6 +39,16 @@ "default": "./dist/request.js" } }, + "./writers/pino": { + "require": { + "types": "./dist/writers/pino.d.cts", + "default": "./dist/writers/pino.cjs" + }, + "import": { + "types": "./dist/writers/pino.d.ts", + "default": "./dist/writers/pino.js" + } + }, "./package.json": "./package.json" }, "files": [ @@ -48,10 +58,19 @@ "build": "pkgroll --clean-dist", "prepack": "yarn build" }, + "peerDependencies": { + "pino": "^9.6.0" + }, + "peerDependenciesMeta": { + "pino": { + "optional": true + } + }, "devDependencies": { "@types/quick-format-unescaped": "^4.0.3", "@whatwg-node/disposablestack": "^0.0.6", "fast-safe-stringify": "^2.1.1", + "pino": "^9.6.0", "pkgroll": "2.11.2", "quick-format-unescaped": "^4.0.4" }, diff --git a/packages/logger/src/writers/pino.ts b/packages/logger/src/writers/pino.ts new file mode 100644 index 000000000..2d24f7c17 --- /dev/null +++ b/packages/logger/src/writers/pino.ts @@ -0,0 +1,18 @@ +import type { Logger as PinoLogger } from 'pino'; +import { LogLevel } from '../logger'; +import { Attributes } from '../utils'; +import { LogWriter } from './common'; + +export class PinoLogWriter implements LogWriter { + #pinoLogger: PinoLogger; + constructor(pinoLogger: PinoLogger) { + this.#pinoLogger = pinoLogger; + } + write( + level: LogLevel, + attrs: Attributes | null | undefined, + msg: string | null | undefined, + ): void { + this.#pinoLogger[level](attrs, msg || undefined); + } +} diff --git a/tsconfig.json b/tsconfig.json index 01774de61..69d63598b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -63,6 +63,9 @@ "@graphql-tools/executor-*": ["./packages/executors/*/src/index.ts"], "@graphql-hive/logger": ["./packages/logger/src/index.ts"], "@graphql-hive/logger/request": ["./packages/logger/src/request.ts"], + "@graphql-hive/logger/writers/pino": [ + "./packages/logger/src/writers/pino.ts" + ], "@graphql-hive/logger-json": ["./packages/logger-json/src/index.ts"], "@graphql-hive/logger-winston": [ "./packages/logger-winston/src/index.ts" diff --git a/yarn.lock b/yarn.lock index 95ed34b7d..4a9379078 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4133,8 +4133,14 @@ __metadata: "@types/quick-format-unescaped": "npm:^4.0.3" "@whatwg-node/disposablestack": "npm:^0.0.6" fast-safe-stringify: "npm:^2.1.1" + pino: "npm:^9.6.0" pkgroll: "npm:2.11.2" quick-format-unescaped: "npm:^4.0.4" + peerDependencies: + pino: ^9.6.0 + peerDependenciesMeta: + pino: + optional: true languageName: unknown linkType: soft From f9e619d8c2d53e8dd8de80192113a2d7240d8c4f Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Mon, 21 Apr 2025 17:12:02 +0200 Subject: [PATCH 123/157] winston logger --- packages/logger/package.json | 16 +++++++++++++++- packages/logger/src/writers/winston.ts | 22 ++++++++++++++++++++++ tsconfig.json | 3 +++ yarn.lock | 3 +++ 4 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 packages/logger/src/writers/winston.ts diff --git a/packages/logger/package.json b/packages/logger/package.json index 09ff11c01..d9203afe4 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -49,6 +49,16 @@ "default": "./dist/writers/pino.js" } }, + "./writers/winston": { + "require": { + "types": "./dist/writers/winston.d.cts", + "default": "./dist/writers/winston.cjs" + }, + "import": { + "types": "./dist/writers/winston.d.ts", + "default": "./dist/writers/winston.js" + } + }, "./package.json": "./package.json" }, "files": [ @@ -64,6 +74,9 @@ "peerDependenciesMeta": { "pino": { "optional": true + }, + "winston": { + "optional": true } }, "devDependencies": { @@ -72,7 +85,8 @@ "fast-safe-stringify": "^2.1.1", "pino": "^9.6.0", "pkgroll": "2.11.2", - "quick-format-unescaped": "^4.0.4" + "quick-format-unescaped": "^4.0.4", + "winston": "^3.17.0" }, "sideEffects": false, "dependencies.info": "all of the dependencies are in devDependencies which will bundle them into the package using pkgroll making pkgroll ultimately zero-dep with a smaller footprint because of tree-shaking" diff --git a/packages/logger/src/writers/winston.ts b/packages/logger/src/writers/winston.ts new file mode 100644 index 000000000..6a5460847 --- /dev/null +++ b/packages/logger/src/writers/winston.ts @@ -0,0 +1,22 @@ +import type { Logger as WinstonLogger } from 'winston'; +import { LogLevel } from '../logger'; +import { Attributes } from '../utils'; +import { LogWriter } from './common'; + +export class WinstonLogWriter implements LogWriter { + #winstonLogger: WinstonLogger; + constructor(winstonLogger: WinstonLogger) { + this.#winstonLogger = winstonLogger; + } + write( + level: LogLevel, + attrs: Attributes | null | undefined, + msg: string | null | undefined, + ): void { + if (msg) { + this.#winstonLogger[level === 'trace' ? 'verbose' : level](msg, attrs); + } else { + this.#winstonLogger[level === 'trace' ? 'verbose' : level](attrs); + } + } +} diff --git a/tsconfig.json b/tsconfig.json index 69d63598b..5d910506f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -66,6 +66,9 @@ "@graphql-hive/logger/writers/pino": [ "./packages/logger/src/writers/pino.ts" ], + "@graphql-hive/logger/writers/winston": [ + "./packages/logger/src/writers/winston.ts" + ], "@graphql-hive/logger-json": ["./packages/logger-json/src/index.ts"], "@graphql-hive/logger-winston": [ "./packages/logger-winston/src/index.ts" diff --git a/yarn.lock b/yarn.lock index 4a9379078..e6a02d5da 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4136,11 +4136,14 @@ __metadata: pino: "npm:^9.6.0" pkgroll: "npm:2.11.2" quick-format-unescaped: "npm:^4.0.4" + winston: "npm:^3.17.0" peerDependencies: pino: ^9.6.0 peerDependenciesMeta: pino: optional: true + winston: + optional: true languageName: unknown linkType: soft From aef52a3f5fad695f89d2e51858ba88dcfb74dc38 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Mon, 21 Apr 2025 17:22:34 +0200 Subject: [PATCH 124/157] document other --- packages/logger/README.md | 68 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/packages/logger/README.md b/packages/logger/README.md index 090ac7c70..634193351 100644 --- a/packages/logger/README.md +++ b/packages/logger/README.md @@ -461,6 +461,74 @@ $ LOG_JSON_PRETTY=1 node example.js ``` +### Optional Writers + +Hive Logger includes some writers for common loggers of the JavaScript ecosystem with optional peer dependencies. + +#### `PinoLogWriter` + +Use the [Node.js `pino` logger library](https://github.com/pinojs/pino) for writing Hive Logger's logs. + +`pino` is an optional peer dependency, so you must install it first. + +```sh +npm i pino pino-pretty +``` + +```ts +import { Logger } from '@graphql-hive/logger'; +import { PinoLogWriter } from '@graphql-hive/logger/writers/pino'; +import pino from 'pino'; + +const pinoLogger = pino({ + transport: { + target: 'pino-pretty', + }, +}); + +const log = new Logger({ writers: [new PinoLogWriter(pinoLogger)] }); + +log.info({ some: 'attributes' }, 'hello world'); +``` + + +```sh +[14:00:00.000] INFO (20744): hello world + some: "attributes" +``` + + +#### `WinstonLogWriter` + +Use the [`winston` logger library](https://github.com/winstonjs/winston) for writing Hive Logger's logs. + +`winston` is an optional peer dependency, so you must install it first. + +```sh +npm i winston +``` + +```ts +import { Logger } from '@graphql-hive/logger'; +import { WinstonLogWriter } from '@graphql-hive/logger/writers/winston'; +import winston from 'winston'; + +const winstonLogger = winston.createLogger({ + transports: [new winston.transports.Console()], +}); + +const log = new Logger({ writers: [new WinstonLogWriter(winstonLogger)] }); + +log.info({ some: 'attributes' }, 'hello world'); +``` + +```sh +{"level":"info","message":"hello world","some":"attributes"} +``` + +> [!IMPORTANT] +> Winston logger does not have a "trace" log level. Hive Logger will instead use "verbose" when writing logs to Winston. + ### Custom Writers You can implement custom log writers for the Hive Logger by creating a class that implements the `LogWriter` interface. This interface requires a single `write` method, which receives the log level, attributes, and message. From 8cec70e7f13e29fe644326ea0dd7860a42d8ced1 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Mon, 21 Apr 2025 17:24:26 +0200 Subject: [PATCH 125/157] winstons node js --- packages/logger/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/logger/README.md b/packages/logger/README.md index 634193351..4c9d00241 100644 --- a/packages/logger/README.md +++ b/packages/logger/README.md @@ -500,7 +500,7 @@ log.info({ some: 'attributes' }, 'hello world'); #### `WinstonLogWriter` -Use the [`winston` logger library](https://github.com/winstonjs/winston) for writing Hive Logger's logs. +Use the [Node.js `winston` logger library](https://github.com/winstonjs/winston) for writing Hive Logger's logs. `winston` is an optional peer dependency, so you must install it first. From 834dff8c31f495c325cb814921657f60d1ea4544 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Mon, 21 Apr 2025 17:24:46 +0200 Subject: [PATCH 126/157] use baseloger pino --- packages/logger/src/writers/pino.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/logger/src/writers/pino.ts b/packages/logger/src/writers/pino.ts index 2d24f7c17..84ea435bb 100644 --- a/packages/logger/src/writers/pino.ts +++ b/packages/logger/src/writers/pino.ts @@ -1,4 +1,4 @@ -import type { Logger as PinoLogger } from 'pino'; +import type { BaseLogger as PinoLogger } from 'pino'; import { LogLevel } from '../logger'; import { Attributes } from '../utils'; import { LogWriter } from './common'; From 713779dab53aca6e34ecb1e9cd2aef5a8cd357ef Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Mon, 21 Apr 2025 17:26:32 +0200 Subject: [PATCH 127/157] use pino writer --- e2e/graphos-polling/services/gateway-fastify.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/e2e/graphos-polling/services/gateway-fastify.ts b/e2e/graphos-polling/services/gateway-fastify.ts index a542af8fe..77dc1b9b7 100644 --- a/e2e/graphos-polling/services/gateway-fastify.ts +++ b/e2e/graphos-polling/services/gateway-fastify.ts @@ -1,4 +1,5 @@ -import { createGatewayRuntime } from '@graphql-hive/gateway-runtime'; +import { createGatewayRuntime, Logger } from '@graphql-hive/gateway-runtime'; +import { PinoLogWriter } from '@graphql-hive/logger/writers/pino'; import { createOtlpHttpExporter, useOpenTelemetry, @@ -42,6 +43,7 @@ export interface FastifyContext { } const gw = createGatewayRuntime({ + logging: new Logger({ writers: [new PinoLogWriter(app.log)] }), // Align with Fastify requestId: { // Use the same header name as Fastify From 62755f1aa5f50308b6914d4cf568998f8b362918 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Mon, 21 Apr 2025 17:27:51 +0200 Subject: [PATCH 128/157] remove legacy loggers --- packages/logger-json/CHANGELOG.md | 40 ---- packages/logger-json/package.json | 56 ------ packages/logger-json/src/index.ts | 185 ------------------ packages/logger-pino/CHANGELOG.md | 25 --- packages/logger-pino/package.json | 58 ------ packages/logger-pino/src/index.ts | 125 ------------ packages/logger-pino/tests/pino.spec.ts | 148 -------------- packages/logger-winston/CHANGELOG.md | 47 ----- packages/logger-winston/package.json | 57 ------ packages/logger-winston/src/index.ts | 102 ---------- packages/logger-winston/tests/winston.spec.ts | 147 -------------- packages/runtime/package.json | 1 - tsconfig.json | 5 - yarn.lock | 73 +++---- 14 files changed, 22 insertions(+), 1047 deletions(-) delete mode 100644 packages/logger-json/CHANGELOG.md delete mode 100644 packages/logger-json/package.json delete mode 100644 packages/logger-json/src/index.ts delete mode 100644 packages/logger-pino/CHANGELOG.md delete mode 100644 packages/logger-pino/package.json delete mode 100644 packages/logger-pino/src/index.ts delete mode 100644 packages/logger-pino/tests/pino.spec.ts delete mode 100644 packages/logger-winston/CHANGELOG.md delete mode 100644 packages/logger-winston/package.json delete mode 100644 packages/logger-winston/src/index.ts delete mode 100644 packages/logger-winston/tests/winston.spec.ts diff --git a/packages/logger-json/CHANGELOG.md b/packages/logger-json/CHANGELOG.md deleted file mode 100644 index 12d75dc21..000000000 --- a/packages/logger-json/CHANGELOG.md +++ /dev/null @@ -1,40 +0,0 @@ -# @graphql-hive/logger-json - -## 0.0.4 - -### Patch Changes - -- [#946](https://github.com/graphql-hive/gateway/pull/946) [`7d771d8`](https://github.com/graphql-hive/gateway/commit/7d771d89ff6d731b1025acfc5eb197541a6d5d35) Thanks [@ardatan](https://github.com/ardatan)! - dependencies updates: - - - Updated dependency [`@graphql-mesh/utils@^0.104.2` ↗︎](https://www.npmjs.com/package/@graphql-mesh/utils/v/0.104.2) (from `^0.104.1`, in `dependencies`) - -## 0.0.3 - -### Patch Changes - -- [#706](https://github.com/graphql-hive/gateway/pull/706) [`e393337`](https://github.com/graphql-hive/gateway/commit/e393337ecb40beffb79748b19b5aa8f2fd9197b7) Thanks [@EmrysMyrddin](https://github.com/EmrysMyrddin)! - dependencies updates: - - - Updated dependency [`@graphql-mesh/utils@^0.104.1` ↗︎](https://www.npmjs.com/package/@graphql-mesh/utils/v/0.104.1) (from `^0.104.0`, in `dependencies`) - -- [#775](https://github.com/graphql-hive/gateway/pull/775) [`33f7dfd`](https://github.com/graphql-hive/gateway/commit/33f7dfdb10eef2a1e7f6dffe0ce6e4bb3cc7c2c6) Thanks [@renovate](https://github.com/apps/renovate)! - dependencies updates: - - - Updated dependency [`@graphql-mesh/types@^0.104.0` ↗︎](https://www.npmjs.com/package/@graphql-mesh/types/v/0.104.0) (from `^0.103.18`, in `dependencies`) - - Updated dependency [`@graphql-mesh/utils@^0.104.0` ↗︎](https://www.npmjs.com/package/@graphql-mesh/utils/v/0.104.0) (from `^0.103.18`, in `dependencies`) - -## 0.0.2 - -### Patch Changes - -- [#697](https://github.com/graphql-hive/gateway/pull/697) [`6cc87c6`](https://github.com/graphql-hive/gateway/commit/6cc87c6e9aa0cbb9eff517eeec92d57b9c96d39e) Thanks [@renovate](https://github.com/apps/renovate)! - dependencies updates: - - - Updated dependency [`@graphql-mesh/types@^0.103.18` ↗︎](https://www.npmjs.com/package/@graphql-mesh/types/v/0.103.18) (from `^0.103.16`, in `dependencies`) - - Updated dependency [`@graphql-mesh/utils@^0.103.18` ↗︎](https://www.npmjs.com/package/@graphql-mesh/utils/v/0.103.18) (from `^0.103.16`, in `dependencies`) - -## 0.0.1 - -### Patch Changes - -- [#642](https://github.com/graphql-hive/gateway/pull/642) [`30e41a6`](https://github.com/graphql-hive/gateway/commit/30e41a6f5b97c42ae548564bce3f6e4a92b1225f) Thanks [@ardatan](https://github.com/ardatan)! - New JSON-based logger - - By default, it prints pretty still to the console unless NODE_ENV is production. - For JSON output, set the `LOG_FORMAT` environment variable to `json`. diff --git a/packages/logger-json/package.json b/packages/logger-json/package.json deleted file mode 100644 index 47cc396d6..000000000 --- a/packages/logger-json/package.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "name": "@graphql-hive/logger-json", - "version": "0.0.4", - "type": "module", - "repository": { - "type": "git", - "url": "git+https://github.com/graphql-hive/gateway.git", - "directory": "packages/logger-json" - }, - "author": { - "email": "contact@the-guild.dev", - "name": "The Guild", - "url": "https://the-guild.dev" - }, - "license": "MIT", - "engines": { - "node": ">=18.0.0" - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "exports": { - ".": { - "require": { - "types": "./dist/index.d.cts", - "default": "./dist/index.cjs" - }, - "import": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - } - }, - "./package.json": "./package.json" - }, - "files": [ - "dist" - ], - "scripts": { - "build": "pkgroll --clean-dist", - "prepack": "yarn build" - }, - "peerDependencies": { - "graphql": "^15.9.0 || ^16.9.0" - }, - "dependencies": { - "@graphql-mesh/cross-helpers": "^0.4.10", - "@graphql-mesh/types": "^0.104.0", - "@graphql-mesh/utils": "^0.104.2", - "cross-inspect": "^1.0.1", - "tslib": "^2.8.1" - }, - "devDependencies": { - "graphql": "^16.9.0", - "pkgroll": "2.12.2" - }, - "sideEffects": false -} diff --git a/packages/logger-json/src/index.ts b/packages/logger-json/src/index.ts deleted file mode 100644 index 712d7d7e1..000000000 --- a/packages/logger-json/src/index.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { process } from '@graphql-mesh/cross-helpers'; -import type { LazyLoggerMessage, Logger } from '@graphql-mesh/types'; -import { LogLevel } from '@graphql-mesh/utils'; -import { inspect } from 'cross-inspect'; - -export interface JSONLoggerOptions { - name?: string; - meta?: Record; - level?: LogLevel; - console?: Console; -} -function truthy(val: unknown) { - return ( - val === true || - val === 1 || - ['1', 't', 'true', 'y', 'yes'].includes(String(val)) - ); -} - -declare global { - var DEBUG: string; -} - -export class JSONLogger implements Logger { - name?: string; - meta: Record; - logLevel: LogLevel; - console: Console; - constructor(opts?: JSONLoggerOptions) { - this.name = opts?.name; - this.console = opts?.console || console; - this.meta = opts?.meta || {}; - const debugStrs = [process.env['DEBUG'], globalThis.DEBUG]; - if (opts?.level != null) { - this.logLevel = opts.level; - } else { - this.logLevel = LogLevel.info; - for (const debugStr of debugStrs) { - if (debugStr) { - if (truthy(debugStr)) { - this.logLevel = LogLevel.debug; - break; - } - if (opts?.name) { - if (debugStr?.toString()?.includes(opts.name)) { - this.logLevel = LogLevel.debug; - break; - } - } - } - } - } - } - - log(...messageArgs: LazyLoggerMessage[]) { - if (this.logLevel > LogLevel.info) { - return; - } - const finalMessage = this.prepareFinalMessage('info', messageArgs); - this.console.log(finalMessage); - } - - warn(...messageArgs: LazyLoggerMessage[]) { - if (this.logLevel > LogLevel.warn) { - return; - } - const finalMessage = this.prepareFinalMessage('warn', messageArgs); - this.console.warn(finalMessage); - } - - info(...messageArgs: LazyLoggerMessage[]) { - if (this.logLevel > LogLevel.info) { - return; - } - const finalMessage = this.prepareFinalMessage('info', messageArgs); - this.console.info(finalMessage); - } - - error(...messageArgs: LazyLoggerMessage[]) { - if (this.logLevel > LogLevel.error) { - return; - } - const finalMessage = this.prepareFinalMessage('error', messageArgs); - this.console.error(finalMessage); - } - - debug(...messageArgs: LazyLoggerMessage[]) { - if (this.logLevel > LogLevel.debug) { - return; - } - const finalMessage = this.prepareFinalMessage('debug', messageArgs); - this.console.debug(finalMessage); - } - - child(nameOrMeta: string | Record) { - let newName: string | undefined; - let newMeta: Record; - if (typeof nameOrMeta === 'string') { - newName = this.name ? `${this.name}, ${nameOrMeta}` : nameOrMeta; - newMeta = this.meta; - } else if (typeof nameOrMeta === 'object') { - newName = this.name; - newMeta = { ...this.meta, ...nameOrMeta }; - } else { - throw new Error('Invalid argument type'); - } - return new JSONLogger({ - name: newName, - meta: newMeta, - level: this.logLevel, - console: this.console, - }); - } - - addPrefix(prefix: string | Record) { - if (typeof prefix === 'string') { - this.name = this.name ? `${this.name}, ${prefix}` : prefix; - } else if (typeof prefix === 'object') { - this.meta = { ...this.meta, ...prefix }; - } - return this; - } - - private prepareFinalMessage(level: string, messageArgs: LazyLoggerMessage[]) { - const flattenedMessageArgs = messageArgs - .flat(Infinity) - .flatMap((messageArg) => { - if (typeof messageArg === 'function') { - messageArg = messageArg(); - } - if (messageArg?.toJSON) { - messageArg = messageArg.toJSON(); - } - if (messageArg instanceof AggregateError) { - return messageArg.errors; - } - return messageArg; - }); - const finalMessage: Record = { - ...this.meta, - level, - time: new Date().toISOString(), - }; - if (this.name) { - finalMessage['name'] = this.name; - } - const extras: any[] = []; - for (let messageArg of flattenedMessageArgs) { - if (messageArg == null) { - continue; - } - const typeofMessageArg = typeof messageArg; - if ( - typeofMessageArg === 'string' || - typeofMessageArg === 'number' || - typeofMessageArg === 'boolean' - ) { - finalMessage['msg'] = finalMessage['msg'] - ? finalMessage['msg'] + ', ' + messageArg - : messageArg; - } else if (typeofMessageArg === 'object') { - if (messageArg instanceof Error) { - finalMessage['msg'] = finalMessage['msg'] - ? finalMessage['msg'] + ', ' + messageArg.message - : messageArg.message; - finalMessage['stack'] = messageArg.stack; - } else if ( - Object.prototype.toString.call(messageArg).startsWith('[object') - ) { - Object.assign(finalMessage, messageArg); - } else { - extras.push(messageArg); - } - } - } - if (extras.length) { - if (extras.length === 1) { - finalMessage['extras'] = inspect(extras[0]); - } else { - finalMessage['extras'] = extras.map((extra) => inspect(extra)); - } - } - return JSON.stringify(finalMessage); - } -} diff --git a/packages/logger-pino/CHANGELOG.md b/packages/logger-pino/CHANGELOG.md deleted file mode 100644 index 1086fdd70..000000000 --- a/packages/logger-pino/CHANGELOG.md +++ /dev/null @@ -1,25 +0,0 @@ -# @graphql-hive/logger-pino - -## 1.0.1 - -### Patch Changes - -- [#1156](https://github.com/graphql-hive/gateway/pull/1156) [`fb74009`](https://github.com/graphql-hive/gateway/commit/fb740098652dba2e9107981d1f4e362143478451) Thanks [@dependabot](https://github.com/apps/dependabot)! - dependencies updates: - - - Updated dependency [`pino@^9.7.0` ↗︎](https://www.npmjs.com/package/pino/v/9.7.0) (from `^9.6.0`, in `peerDependencies`) - -## 1.0.0 - -### Major Changes - -- [#946](https://github.com/graphql-hive/gateway/pull/946) [`7d771d8`](https://github.com/graphql-hive/gateway/commit/7d771d89ff6d731b1025acfc5eb197541a6d5d35) Thanks [@ardatan](https://github.com/ardatan)! - New Pino integration (also helpful for Fastify integration); - - ```ts - import { defineConfig } from '@graphql-hive/gateway'; - import { createLoggerFromPino } from '@graphql-hive/logger-pino'; - import pino from 'pino'; - - export const gatewayConfig = defineConfig({ - logging: createLoggerFromPino(pino({ level: 'info' })), - }); - ``` diff --git a/packages/logger-pino/package.json b/packages/logger-pino/package.json deleted file mode 100644 index d42104bca..000000000 --- a/packages/logger-pino/package.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "name": "@graphql-hive/logger-pino", - "version": "1.0.1", - "type": "module", - "repository": { - "type": "git", - "url": "git+https://github.com/graphql-hive/gateway.git", - "directory": "packages/logger-pino" - }, - "homepage": "https://the-guild.dev/graphql/hive/docs/gateway", - "author": { - "email": "contact@the-guild.dev", - "name": "The Guild", - "url": "https://the-guild.dev" - }, - "license": "MIT", - "engines": { - "node": ">=18.0.0" - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "exports": { - ".": { - "require": { - "types": "./dist/index.d.cts", - "default": "./dist/index.cjs" - }, - "import": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - } - }, - "./package.json": "./package.json" - }, - "files": [ - "dist" - ], - "scripts": { - "build": "pkgroll --clean-dist", - "prepack": "yarn build" - }, - "peerDependencies": { - "graphql": "^15.9.0 || ^16.9.0", - "pino": "^9.7.0" - }, - "dependencies": { - "@graphql-mesh/types": "^0.104.0", - "@graphql-mesh/utils": "^0.104.2", - "@whatwg-node/disposablestack": "^0.0.6", - "tslib": "^2.8.1" - }, - "devDependencies": { - "graphql": "16.11.0", - "pino": "^9.7.0", - "pkgroll": "2.12.2" - }, - "sideEffects": false -} diff --git a/packages/logger-pino/src/index.ts b/packages/logger-pino/src/index.ts deleted file mode 100644 index bd48c9a3a..000000000 --- a/packages/logger-pino/src/index.ts +++ /dev/null @@ -1,125 +0,0 @@ -import type { - LazyLoggerMessage, - Logger as MeshLogger, -} from '@graphql-mesh/types'; -import { LogLevel } from '@graphql-mesh/utils'; -import type pino from 'pino'; - -type PinoWithChild = pino.BaseLogger & { - child: (meta: any) => PinoWithChild; -}; - -function prepareArgs(messageArgs: LazyLoggerMessage[]): Parameters { - const flattenedMessageArgs = messageArgs - .flat(Infinity) - .flatMap((messageArg) => { - if (typeof messageArg === 'function') { - messageArg = messageArg(); - } - if (messageArg?.toJSON) { - messageArg = messageArg.toJSON(); - } - if (messageArg instanceof AggregateError) { - return messageArg.errors; - } - return messageArg; - }); - let message: string = ''; - const extras: any[] = []; - for (let messageArg of flattenedMessageArgs) { - if (messageArg == null) { - continue; - } - const typeofMessageArg = typeof messageArg; - if ( - typeofMessageArg === 'string' || - typeofMessageArg === 'number' || - typeofMessageArg === 'boolean' - ) { - message = message ? message + ', ' + messageArg : messageArg; - } else if (typeofMessageArg === 'object') { - extras.push(messageArg); - } - } - if (extras.length > 0) { - return [Object.assign({}, ...extras), message]; - } - return [message]; -} - -class PinoLoggerAdapter implements MeshLogger { - public name?: string; - constructor( - private pinoLogger: PinoWithChild, - private meta: Record = {}, - ) { - if (meta['name']) { - this.name = meta['name']; - } - } - - get level(): LogLevel { - if (this.pinoLogger.level) { - return LogLevel[this.pinoLogger.level as keyof typeof LogLevel]; - } - return LogLevel.silent; - } - - set level(level: LogLevel) { - this.pinoLogger.level = LogLevel[level]; - } - - isLevelEnabled(level: LogLevel) { - if (this.level > level) { - return false; - } - return true; - } - - log(...args: any[]) { - if (this.isLevelEnabled(LogLevel.info)) { - this.pinoLogger.info(...prepareArgs(args)); - } - } - info(...args: any[]) { - if (this.isLevelEnabled(LogLevel.info)) { - this.pinoLogger.info(...prepareArgs(args)); - } - } - warn(...args: any[]) { - if (this.isLevelEnabled(LogLevel.warn)) { - this.pinoLogger.warn(...prepareArgs(args)); - } - } - error(...args: any[]) { - if (this.isLevelEnabled(LogLevel.error)) { - this.pinoLogger.error(...prepareArgs(args)); - } - } - debug(...lazyArgs: LazyLoggerMessage[]) { - if (this.isLevelEnabled(LogLevel.debug)) { - this.pinoLogger.debug(...prepareArgs(lazyArgs)); - } - } - child(nameOrMeta: string | Record) { - if (typeof nameOrMeta === 'string') { - nameOrMeta = { - name: this.name - ? this.name.includes(nameOrMeta) - ? this.name - : `${this.name}, ${nameOrMeta}` - : nameOrMeta, - }; - } - return new PinoLoggerAdapter(this.pinoLogger.child(nameOrMeta), { - ...this.meta, - ...nameOrMeta, - }); - } -} - -export function createLoggerFromPino( - pinoLogger: PinoWithChild, -): PinoLoggerAdapter { - return new PinoLoggerAdapter(pinoLogger); -} diff --git a/packages/logger-pino/tests/pino.spec.ts b/packages/logger-pino/tests/pino.spec.ts deleted file mode 100644 index bde7486a7..000000000 --- a/packages/logger-pino/tests/pino.spec.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { hostname } from 'node:os'; -import { Writable } from 'node:stream'; -import pino from 'pino'; -import { describe, expect, it } from 'vitest'; -import { createLoggerFromPino } from '../src'; - -describe('Pino', () => { - let log = ''; - let lastCallback = () => {}; - const stream = new Writable({ - write(chunk, _encoding, callback) { - log = chunk.toString('utf-8'); - lastCallback = callback; - }, - }); - const logLevels = ['error', 'warn', 'info', 'debug'] as const; - for (const level of logLevels) { - describe(`Level: ${level}`, () => { - it('basic', async () => { - const logger = pino( - { - level, - }, - stream, - ); - const loggerAdapter = createLoggerFromPino(logger); - const testData = [ - 'Hello', - ['World'], - { foo: 'bar' }, - 42, - true, - null, - undefined, - () => 'Expensive', - ]; - loggerAdapter[level](...testData); - lastCallback(); - const logJson = JSON.parse(log); - expect(logJson).toEqual({ - level: pino.levels.values[level], - foo: 'bar', - msg: 'Hello, World, 42, true, Expensive', - pid: process.pid, - time: expect.any(Number), - hostname: hostname(), - }); - }); - it('child', async () => { - const logger = pino( - { - level, - }, - stream, - ); - const loggerAdapter = createLoggerFromPino(logger); - const testData = [ - 'Hello', - ['World'], - { foo: 'bar' }, - 42, - true, - null, - undefined, - () => 'Expensive', - ]; - const childLogger = loggerAdapter.child('child'); - childLogger[level](...testData); - lastCallback(); - const logJson = JSON.parse(log); - expect(logJson).toEqual({ - level: pino.levels.values[level], - foo: 'bar', - msg: 'Hello, World, 42, true, Expensive', - name: 'child', - pid: process.pid, - time: expect.any(Number), - hostname: hostname(), - }); - }); - it('deduplicate names', async () => { - const logger = pino( - { - level, - }, - stream, - ); - const loggerAdapter = createLoggerFromPino(logger); - const testData = [ - 'Hello', - ['World'], - { foo: 'bar' }, - 42, - true, - null, - undefined, - () => 'Expensive', - ]; - const childLogger = loggerAdapter.child('child').child('child'); - childLogger[level](...testData); - lastCallback(); - const logJson = JSON.parse(log); - expect(logJson).toEqual({ - level: pino.levels.values[level], - foo: 'bar', - msg: 'Hello, World, 42, true, Expensive', - name: 'child', - pid: process.pid, - time: expect.any(Number), - hostname: hostname(), - }); - }); - it('nested', async () => { - const logger = pino( - { - level, - }, - stream, - ); - const loggerAdapter = createLoggerFromPino(logger); - const testData = [ - 'Hello', - ['World'], - { foo: 'bar' }, - 42, - true, - null, - undefined, - () => 'Expensive', - ]; - const childLogger = loggerAdapter.child('child'); - const nestedLogger = childLogger.child('nested'); - nestedLogger[level](...testData); - lastCallback(); - const logJson = JSON.parse(log); - expect(logJson).toEqual({ - level: pino.levels.values[level], - foo: 'bar', - msg: 'Hello, World, 42, true, Expensive', - name: 'child, nested', - pid: process.pid, - time: expect.any(Number), - hostname: hostname(), - }); - }); - }); - } -}); diff --git a/packages/logger-winston/CHANGELOG.md b/packages/logger-winston/CHANGELOG.md deleted file mode 100644 index a9474a769..000000000 --- a/packages/logger-winston/CHANGELOG.md +++ /dev/null @@ -1,47 +0,0 @@ -# @graphql-hive/logger-winston - -## 1.0.2 - -### Patch Changes - -- [#727](https://github.com/graphql-hive/gateway/pull/727) [`c54a080`](https://github.com/graphql-hive/gateway/commit/c54a080b8b9c477ed55dd7c23fc8fcae9139bec8) Thanks [@renovate](https://github.com/apps/renovate)! - dependencies updates: - - - Updated dependency [`@whatwg-node/disposablestack@^0.0.6` ↗︎](https://www.npmjs.com/package/@whatwg-node/disposablestack/v/0.0.6) (from `^0.0.5`, in `dependencies`) - -- [#775](https://github.com/graphql-hive/gateway/pull/775) [`33f7dfd`](https://github.com/graphql-hive/gateway/commit/33f7dfdb10eef2a1e7f6dffe0ce6e4bb3cc7c2c6) Thanks [@renovate](https://github.com/apps/renovate)! - dependencies updates: - - - Updated dependency [`@graphql-mesh/types@^0.104.0` ↗︎](https://www.npmjs.com/package/@graphql-mesh/types/v/0.104.0) (from `^0.103.18`, in `dependencies`) - -## 1.0.1 - -### Patch Changes - -- [#696](https://github.com/graphql-hive/gateway/pull/696) [`a289faa`](https://github.com/graphql-hive/gateway/commit/a289faae1469eb46f1458be341d21909fe5f8f8f) Thanks [@ardatan](https://github.com/ardatan)! - dependencies updates: - - - Updated dependency [`@graphql-mesh/types@^0.103.18` ↗︎](https://www.npmjs.com/package/@graphql-mesh/types/v/0.103.18) (from `^0.103.6`, in `dependencies`) - -## 1.0.0 - -### Major Changes - -- [#622](https://github.com/graphql-hive/gateway/pull/622) [`16f9bd9`](https://github.com/graphql-hive/gateway/commit/16f9bd981d5779c585c00bf79e790c94b00326f1) Thanks [@ardatan](https://github.com/ardatan)! - **Winston Adapter** - - Now you can integrate [Winston](https://github.com/winstonjs/winston) into Hive Gateway on Node.js - - ```ts - import { defineConfig } from '@graphql-hive/gateway'; - import { createLoggerFromWinston } from '@graphql-hive/winston'; - import { createLogger, format, transports } from 'winston'; - - // Create a Winston logger - const winstonLogger = createLogger({ - level: 'info', - format: format.combine(format.timestamp(), format.json()), - transports: [new transports.Console()], - }); - - export const gatewayConfig = defineConfig({ - // Create an adapter for Winston - logging: createLoggerFromWinston(winstonLogger), - }); - ``` diff --git a/packages/logger-winston/package.json b/packages/logger-winston/package.json deleted file mode 100644 index 6238b15b5..000000000 --- a/packages/logger-winston/package.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "name": "@graphql-hive/logger-winston", - "version": "1.0.2", - "type": "module", - "repository": { - "type": "git", - "url": "git+https://github.com/graphql-hive/gateway.git", - "directory": "packages/logger-winston" - }, - "homepage": "https://the-guild.dev/graphql/hive/docs/gateway", - "author": { - "email": "contact@the-guild.dev", - "name": "The Guild", - "url": "https://the-guild.dev" - }, - "license": "MIT", - "engines": { - "node": ">=18.0.0" - }, - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "exports": { - ".": { - "require": { - "types": "./dist/index.d.cts", - "default": "./dist/index.cjs" - }, - "import": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - } - }, - "./package.json": "./package.json" - }, - "files": [ - "dist" - ], - "scripts": { - "build": "pkgroll --clean-dist", - "prepack": "yarn build" - }, - "peerDependencies": { - "graphql": "^15.9.0 || ^16.9.0", - "winston": "^3.17.0" - }, - "dependencies": { - "@graphql-mesh/types": "^0.104.0", - "@whatwg-node/disposablestack": "^0.0.6", - "tslib": "^2.8.1" - }, - "devDependencies": { - "graphql": "16.11.0", - "pkgroll": "2.12.2", - "winston": "^3.17.0" - }, - "sideEffects": false -} diff --git a/packages/logger-winston/src/index.ts b/packages/logger-winston/src/index.ts deleted file mode 100644 index d050b60f0..000000000 --- a/packages/logger-winston/src/index.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { - LazyLoggerMessage, - Logger as MeshLogger, -} from '@graphql-mesh/types'; -import { DisposableSymbols } from '@whatwg-node/disposablestack'; -import type { Logger as WinstonLogger } from 'winston'; - -function prepareArgs(messageArgs: LazyLoggerMessage[]) { - const flattenedMessageArgs = messageArgs - .flat(Infinity) - .flatMap((messageArg) => { - if (typeof messageArg === 'function') { - messageArg = messageArg(); - } - if (messageArg?.toJSON) { - messageArg = messageArg.toJSON(); - } - if (messageArg instanceof AggregateError) { - return messageArg.errors; - } - return messageArg; - }); - let message: string = ''; - const extras: any[] = []; - for (let messageArg of flattenedMessageArgs) { - if (messageArg == null) { - continue; - } - const typeofMessageArg = typeof messageArg; - if ( - typeofMessageArg === 'string' || - typeofMessageArg === 'number' || - typeofMessageArg === 'boolean' - ) { - message = message ? message + ', ' + messageArg : messageArg; - } else if (typeofMessageArg === 'object') { - extras.push(messageArg); - } - } - return [message, ...extras] as const; -} - -class WinstonLoggerAdapter implements MeshLogger, Disposable { - public name?: string; - constructor( - private winstonLogger: WinstonLogger, - private meta: Record = {}, - ) { - if (meta['name']) { - this.name = meta['name']; - } - } - log(...args: any[]) { - if (this.winstonLogger.isInfoEnabled()) { - this.winstonLogger.info(...prepareArgs(args)); - } - } - info(...args: any[]) { - if (this.winstonLogger.isInfoEnabled()) { - this.winstonLogger.info(...prepareArgs(args)); - } - } - warn(...args: any[]) { - if (this.winstonLogger.isWarnEnabled()) { - this.winstonLogger.warn(...prepareArgs(args)); - } - } - error(...args: any[]) { - if (this.winstonLogger.isErrorEnabled()) { - this.winstonLogger.error(...prepareArgs(args)); - } - } - debug(...lazyArgs: LazyLoggerMessage[]) { - if (this.winstonLogger.isDebugEnabled()) { - this.winstonLogger.debug(...prepareArgs(lazyArgs)); - } - } - child(nameOrMeta: string | Record) { - if (typeof nameOrMeta === 'string') { - nameOrMeta = { - name: this.name - ? this.name.includes(nameOrMeta) - ? this.name - : `${this.name}, ${nameOrMeta}` - : nameOrMeta, - }; - } - return new WinstonLoggerAdapter(this.winstonLogger.child(nameOrMeta), { - ...this.meta, - ...nameOrMeta, - }); - } - [DisposableSymbols.dispose]() { - return this.winstonLogger.close(); - } -} - -export function createLoggerFromWinston( - winstonLogger: WinstonLogger, -): WinstonLoggerAdapter { - return new WinstonLoggerAdapter(winstonLogger); -} diff --git a/packages/logger-winston/tests/winston.spec.ts b/packages/logger-winston/tests/winston.spec.ts deleted file mode 100644 index 9b9034dad..000000000 --- a/packages/logger-winston/tests/winston.spec.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { Writable } from 'node:stream'; -import { describe, expect, it } from 'vitest'; -import * as winston from 'winston'; -import { createLoggerFromWinston } from '../src'; - -describe('Winston', () => { - let log = ''; - let lastCallback = () => {}; - const stream = new Writable({ - write(chunk, _encoding, callback) { - log = chunk.toString('utf-8'); - lastCallback = callback; - }, - }); - const logLevels = ['error', 'warn', 'info', 'debug'] as const; - for (const level of logLevels) { - describe(`Level: ${level}`, () => { - it('basic', () => { - const logger = winston.createLogger({ - level, - format: winston.format.json(), - transports: [ - new winston.transports.Stream({ - stream, - }), - ], - }); - using loggerAdapter = createLoggerFromWinston(logger); - const testData = [ - 'Hello', - ['World'], - { foo: 'bar' }, - 42, - true, - null, - undefined, - () => 'Expensive', - ]; - loggerAdapter[level](...testData); - lastCallback(); - const logJson = JSON.parse(log, (_key, value) => value); - expect(logJson).toEqual({ - level, - foo: 'bar', - message: 'Hello, World, 42, true, Expensive', - }); - }); - it('child', () => { - const logger = winston.createLogger({ - level, - format: winston.format.json(), - transports: [ - new winston.transports.Stream({ - stream, - }), - ], - }); - const loggerAdapter = createLoggerFromWinston(logger); - const testData = [ - 'Hello', - ['World'], - { foo: 'bar' }, - 42, - true, - null, - undefined, - () => 'Expensive', - ]; - const childLogger = loggerAdapter.child('child'); - childLogger[level](...testData); - lastCallback(); - const logJson = JSON.parse(log, (_key, value) => value); - expect(logJson).toEqual({ - level, - foo: 'bar', - message: 'Hello, World, 42, true, Expensive', - name: 'child', - }); - }); - it('deduplicate names', () => { - const logger = winston.createLogger({ - level, - format: winston.format.json(), - transports: [ - new winston.transports.Stream({ - stream, - }), - ], - }); - const loggerAdapter = createLoggerFromWinston(logger); - const testData = [ - 'Hello', - ['World'], - { foo: 'bar' }, - 42, - true, - null, - undefined, - () => 'Expensive', - ]; - const childLogger = loggerAdapter.child('child').child('child'); - childLogger[level](...testData); - lastCallback(); - const logJson = JSON.parse(log, (_key, value) => value); - expect(logJson).toEqual({ - level, - foo: 'bar', - message: 'Hello, World, 42, true, Expensive', - name: 'child', - }); - }); - it('nested', () => { - const logger = winston.createLogger({ - level, - format: winston.format.json(), - transports: [ - new winston.transports.Stream({ - stream, - }), - ], - }); - const loggerAdapter = createLoggerFromWinston(logger); - const testData = [ - 'Hello', - ['World'], - { foo: 'bar' }, - 42, - true, - null, - undefined, - () => 'Expensive', - ]; - const childLogger = loggerAdapter.child('child'); - const nestedLogger = childLogger.child('nested'); - nestedLogger[level](...testData); - lastCallback(); - const logJson = JSON.parse(log, (_key, value) => value); - expect(logJson).toEqual({ - level, - foo: 'bar', - message: 'Hello, World, 42, true, Expensive', - name: 'child, nested', - }); - }); - }); - } -}); diff --git a/packages/runtime/package.json b/packages/runtime/package.json index f1223ff11..c2e6ac18d 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -50,7 +50,6 @@ "@envelop/instrumentation": "^1.0.0", "@graphql-hive/core": "^0.12.0", "@graphql-hive/logger": "workspace:^", - "@graphql-hive/logger-json": "workspace:^", "@graphql-hive/pubsub": "workspace:^", "@graphql-hive/signal": "workspace:^", "@graphql-hive/yoga": "^0.42.1", diff --git a/tsconfig.json b/tsconfig.json index 5d910506f..cca157be4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -69,11 +69,6 @@ "@graphql-hive/logger/writers/winston": [ "./packages/logger/src/writers/winston.ts" ], - "@graphql-hive/logger-json": ["./packages/logger-json/src/index.ts"], - "@graphql-hive/logger-winston": [ - "./packages/logger-winston/src/index.ts" - ], - "@graphql-hive/logger-pino": ["./packages/logger-pino/src/index.ts"], "@graphql-hive/plugin-aws-sigv4": [ "./packages/plugins/aws-sigv4/src/index.ts" ], diff --git a/yarn.lock b/yarn.lock index e6a02d5da..18c4a37c8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3945,7 +3945,6 @@ __metadata: "@envelop/instrumentation": "npm:^1.0.0" "@graphql-hive/core": "npm:^0.12.0" "@graphql-hive/logger": "workspace:^" - "@graphql-hive/logger-json": "workspace:^" "@graphql-hive/pubsub": "workspace:^" "@graphql-hive/signal": "workspace:^" "@graphql-hive/yoga": "npm:^0.42.1" @@ -4077,55 +4076,6 @@ __metadata: languageName: unknown linkType: soft -"@graphql-hive/logger-json@workspace:^, @graphql-hive/logger-json@workspace:packages/logger-json": - version: 0.0.0-use.local - resolution: "@graphql-hive/logger-json@workspace:packages/logger-json" - dependencies: - "@graphql-mesh/cross-helpers": "npm:^0.4.10" - "@graphql-mesh/types": "npm:^0.104.0" - "@graphql-mesh/utils": "npm:^0.104.2" - cross-inspect: "npm:^1.0.1" - graphql: "npm:^16.9.0" - pkgroll: "npm:2.12.2" - tslib: "npm:^2.8.1" - peerDependencies: - graphql: ^15.9.0 || ^16.9.0 - languageName: unknown - linkType: soft - -"@graphql-hive/logger-pino@workspace:packages/logger-pino": - version: 0.0.0-use.local - resolution: "@graphql-hive/logger-pino@workspace:packages/logger-pino" - dependencies: - "@graphql-mesh/types": "npm:^0.104.0" - "@graphql-mesh/utils": "npm:^0.104.2" - "@whatwg-node/disposablestack": "npm:^0.0.6" - graphql: "npm:16.11.0" - pino: "npm:^9.7.0" - pkgroll: "npm:2.12.2" - tslib: "npm:^2.8.1" - peerDependencies: - graphql: ^15.9.0 || ^16.9.0 - pino: ^9.7.0 - languageName: unknown - linkType: soft - -"@graphql-hive/logger-winston@workspace:packages/logger-winston": - version: 0.0.0-use.local - resolution: "@graphql-hive/logger-winston@workspace:packages/logger-winston" - dependencies: - "@graphql-mesh/types": "npm:^0.104.0" - "@whatwg-node/disposablestack": "npm:^0.0.6" - graphql: "npm:16.11.0" - pkgroll: "npm:2.12.2" - tslib: "npm:^2.8.1" - winston: "npm:^3.17.0" - peerDependencies: - graphql: ^15.9.0 || ^16.9.0 - winston: ^3.17.0 - languageName: unknown - linkType: soft - "@graphql-hive/logger@workspace:^, @graphql-hive/logger@workspace:packages/logger": version: 0.0.0-use.local resolution: "@graphql-hive/logger@workspace:packages/logger" @@ -17870,7 +17820,7 @@ __metadata: languageName: node linkType: hard -"pino@npm:^9.0.0, pino@npm:^9.7.0": +"pino@npm:^9.0.0": version: 9.7.0 resolution: "pino@npm:9.7.0" dependencies: @@ -17891,6 +17841,27 @@ __metadata: languageName: node linkType: hard +"pino@npm:^9.6.0": + version: 9.6.0 + resolution: "pino@npm:9.6.0" + dependencies: + atomic-sleep: "npm:^1.0.0" + fast-redact: "npm:^3.1.1" + on-exit-leak-free: "npm:^2.1.0" + pino-abstract-transport: "npm:^2.0.0" + pino-std-serializers: "npm:^7.0.0" + process-warning: "npm:^4.0.0" + quick-format-unescaped: "npm:^4.0.3" + real-require: "npm:^0.2.0" + safe-stable-stringify: "npm:^2.3.1" + sonic-boom: "npm:^4.0.1" + thread-stream: "npm:^3.0.0" + bin: + pino: bin.js + checksum: 10c0/bcd1e9d9b301bea13b95689ca9ad7105ae9451928fb6c0b67b3e58c5fe37cea1d40665f3d6641e3da00be0bbc17b89031e67abbc8ea6aac6164f399309fd78e7 + languageName: node + linkType: hard + "pirates@npm:^4.0.1, pirates@npm:^4.0.4, pirates@npm:^4.0.6": version: 4.0.7 resolution: "pirates@npm:4.0.7" From 689f5d1451e6fd2cc75f54e1c6a73da3f74b43fe Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 21 Apr 2025 15:30:15 +0000 Subject: [PATCH 129/157] chore(dependencies): updated changesets for modified dependencies --- .changeset/@graphql-hive_gateway-runtime-1030-dependencies.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/@graphql-hive_gateway-runtime-1030-dependencies.md b/.changeset/@graphql-hive_gateway-runtime-1030-dependencies.md index 1c3382fe5..102b3e27b 100644 --- a/.changeset/@graphql-hive_gateway-runtime-1030-dependencies.md +++ b/.changeset/@graphql-hive_gateway-runtime-1030-dependencies.md @@ -6,3 +6,4 @@ dependencies updates: - Added dependency [`@envelop/instrumentation@^1.0.0` ↗︎](https://www.npmjs.com/package/@envelop/instrumentation/v/1.0.0) (to `dependencies`) - Added dependency [`@graphql-hive/logger@workspace:^` ↗︎](https://www.npmjs.com/package/@graphql-hive/logger/v/workspace:^) (to `dependencies`) +- Removed dependency [`@graphql-hive/logger-json@workspace:^` ↗︎](https://www.npmjs.com/package/@graphql-hive/logger-json/v/workspace:^) (from `dependencies`) From 3aa144de0717ad70bbad04e3457ab754357a609d Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Mon, 21 Apr 2025 18:02:50 +0200 Subject: [PATCH 130/157] change case --- packages/logger/src/{LegacyLogger.ts => legacyLogger.ts} | 0 packages/logger/src/{Logger.ts => logger.ts} | 0 .../logger/tests/{LegacyLogger.test.ts => legacyLogger.test.ts} | 0 packages/logger/tests/{Logger.test.ts => logger.test.ts} | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename packages/logger/src/{LegacyLogger.ts => legacyLogger.ts} (100%) rename packages/logger/src/{Logger.ts => logger.ts} (100%) rename packages/logger/tests/{LegacyLogger.test.ts => legacyLogger.test.ts} (100%) rename packages/logger/tests/{Logger.test.ts => logger.test.ts} (100%) diff --git a/packages/logger/src/LegacyLogger.ts b/packages/logger/src/legacyLogger.ts similarity index 100% rename from packages/logger/src/LegacyLogger.ts rename to packages/logger/src/legacyLogger.ts diff --git a/packages/logger/src/Logger.ts b/packages/logger/src/logger.ts similarity index 100% rename from packages/logger/src/Logger.ts rename to packages/logger/src/logger.ts diff --git a/packages/logger/tests/LegacyLogger.test.ts b/packages/logger/tests/legacyLogger.test.ts similarity index 100% rename from packages/logger/tests/LegacyLogger.test.ts rename to packages/logger/tests/legacyLogger.test.ts diff --git a/packages/logger/tests/Logger.test.ts b/packages/logger/tests/logger.test.ts similarity index 100% rename from packages/logger/tests/Logger.test.ts rename to packages/logger/tests/logger.test.ts From 336b6e31d393807c0465cb2b0e350961aa8e602c Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Mon, 21 Apr 2025 18:19:22 +0200 Subject: [PATCH 131/157] more log detauls and information --- .../hmac-upstream-signature/src/index.ts | 14 +++++++--- packages/plugins/jwt-auth/src/index.ts | 2 +- packages/plugins/prometheus/src/index.ts | 4 ++- packages/runtime/src/plugins/useCacheDebug.ts | 26 +++++++++---------- .../src/plugins/useDelegationPlanDebug.ts | 13 ++++++---- .../runtime/src/plugins/useDemandControl.ts | 2 +- packages/runtime/src/plugins/useFetchDebug.ts | 6 ++--- .../src/plugins/useRetryOnSchemaReload.ts | 2 +- .../src/plugins/useSubgraphExecuteDebug.ts | 17 +++++++----- packages/runtime/src/plugins/useWebhooks.ts | 5 ++-- 10 files changed, 54 insertions(+), 37 deletions(-) diff --git a/packages/plugins/hmac-upstream-signature/src/index.ts b/packages/plugins/hmac-upstream-signature/src/index.ts index bd11f57c9..5a6dc75c0 100644 --- a/packages/plugins/hmac-upstream-signature/src/index.ts +++ b/packages/plugins/hmac-upstream-signature/src/index.ts @@ -94,11 +94,12 @@ export function useHmacUpstreamSignature( fetchAPI = yoga.fetchAPI; }, onSubgraphExecute({ subgraphName, subgraph, executionRequest, log }) { + log = log.child('[useHmacUpstreamSignature] '); log.debug('Running shouldSign for subgraph %s', subgraphName); if (shouldSign({ subgraphName, subgraph, executionRequest })) { log.debug( - 'shouldSign is true for subgraph $s, signing request', + 'shouldSign is true for subgraph %s, signing request', subgraphName, ); textEncoder ||= new fetchAPI.TextEncoder(); @@ -124,7 +125,12 @@ export function useHmacUpstreamSignature( String.fromCharCode(...new Uint8Array(signature)), ); log.debug( - `produced hmac signature for subgraph ${subgraphName}, signature: ${extensionValue}, signed payload: ${serializedExecutionRequest}`, + { + signature: extensionValue, + payload: serializedExecutionRequest, + }, + 'Produced hmac signature for subgraph %s', + subgraphName, ); if (!executionRequest.extensions) { @@ -167,7 +173,9 @@ export function useHmacSignatureValidation( return { onParams({ params, fetchAPI, request }) { - const log = loggerForRequest(options.log, request); + const log = loggerForRequest(options.log, request).child( + '[useHmacSignatureValidation] ', + ); textEncoder ||= new fetchAPI.TextEncoder(); const extension = params.extensions?.[extensionName]; diff --git a/packages/plugins/jwt-auth/src/index.ts b/packages/plugins/jwt-auth/src/index.ts index f0aad76e8..89c9b57c6 100644 --- a/packages/plugins/jwt-auth/src/index.ts +++ b/packages/plugins/jwt-auth/src/index.ts @@ -88,7 +88,7 @@ export function useJWT( log.debug( { payload: jwtData.payload }, - 'Forwarding JWT payload to subgraph %s', + '[useJWT] Forwarding JWT payload to subgraph %s', subgraphName, ); diff --git a/packages/plugins/prometheus/src/index.ts b/packages/plugins/prometheus/src/index.ts index 8b75884b1..3918ac141 100644 --- a/packages/plugins/prometheus/src/index.ts +++ b/packages/plugins/prometheus/src/index.ts @@ -391,7 +391,9 @@ function registryFromYamlConfig( registry$ .then(() => registryProxy.revoke()) - .catch((e) => config.log.error(e, 'Failed to load Prometheus registry')); + .catch((e) => + config.log.error(e, '[usePrometheus] Failed to load Prometheus registry'), + ); return registryProxy.proxy; } diff --git a/packages/runtime/src/plugins/useCacheDebug.ts b/packages/runtime/src/plugins/useCacheDebug.ts index 313ab83b0..78de0c800 100644 --- a/packages/runtime/src/plugins/useCacheDebug.ts +++ b/packages/runtime/src/plugins/useCacheDebug.ts @@ -12,41 +12,41 @@ export function useCacheDebug< log = context.log; }, onCacheGet({ key }) { - log = log.child({ key }); - log.debug('cache get'); + log = log.child({ key }, '[useCacheDebug] '); + log.debug('Get'); return { onCacheGetError({ error }) { - log.error({ key, error }, 'error'); + log.error({ key, error }, 'Error'); }, onCacheHit({ value }) { - log.debug({ key, value }, 'hit'); + log.debug({ key, value }, 'Hit'); }, onCacheMiss() { - log.debug({ key }, 'miss'); + log.debug({ key }, 'Miss'); }, }; }, onCacheSet({ key, value, ttl }) { - log = log.child({ key, value, ttl }); - log.debug('cache set'); + log = log.child({ key, value, ttl }, '[useCacheDebug] '); + log.debug('Set'); return { onCacheSetError({ error }) { - log.error({ error }, 'error'); + log.error({ error }, 'Error'); }, onCacheSetDone() { - log.debug('done'); + log.debug('Done'); }, }; }, onCacheDelete({ key }) { - log = log.child({ key }); - log.debug('cache delete'); + log = log.child({ key }, '[useCacheDebug] '); + log.debug('Delete'); return { onCacheDeleteError({ error }) { - log.error({ error }, 'error'); + log.error({ error }, 'Error'); }, onCacheDeleteDone() { - log.debug('done'); + log.debug('Done'); }, }; }, diff --git a/packages/runtime/src/plugins/useDelegationPlanDebug.ts b/packages/runtime/src/plugins/useDelegationPlanDebug.ts index e0dffd7ea..a4808a8cc 100644 --- a/packages/runtime/src/plugins/useDelegationPlanDebug.ts +++ b/packages/runtime/src/plugins/useDelegationPlanDebug.ts @@ -21,7 +21,10 @@ export function useDelegationPlanDebug< info, }) { const planId = fetchAPI.crypto.randomUUID(); - const log = context.log.child({ planId, typeName }); + const log = context.log.child( + { planId, typeName }, + '[useDelegationPlanDebug] ', + ); log.debug(() => { const logObj: Record = {}; if (variables && Object.keys(variables).length) { @@ -44,7 +47,7 @@ export function useDelegationPlanDebug< logObj['path'] = pathToArray(info.path).join(' | '); } return logObj; - }, 'delegation-plan-start'); + }, 'Start'); return ({ delegationPlan }) => { log.debug( () => ({ @@ -58,7 +61,7 @@ export function useDelegationPlanDebug< return planObj; }), }), - 'delegation-plan-done', + 'Done', ); }; }, @@ -97,10 +100,10 @@ export function useDelegationPlanDebug< ...log, path: pathToArray(info.path).join(' | '), }), - 'delegation-plan-start', + 'Stage start', ); return ({ result }) => { - log.debug(() => result, 'delegation-stage-execute-done'); + log.debug(() => result, 'Stage done'); }; }, }; diff --git a/packages/runtime/src/plugins/useDemandControl.ts b/packages/runtime/src/plugins/useDemandControl.ts index b8586e5de..c7782fb34 100644 --- a/packages/runtime/src/plugins/useDemandControl.ts +++ b/packages/runtime/src/plugins/useDemandControl.ts @@ -87,7 +87,7 @@ export function useDemandControl>({ operationCost, totalCost: costByContext, }, - 'demand-control', + '[useDemandControl]', ); if (maxCost != null && costByContext > maxCost) { throw createGraphQLError( diff --git a/packages/runtime/src/plugins/useFetchDebug.ts b/packages/runtime/src/plugins/useFetchDebug.ts index 80fc5014f..3e87798da 100644 --- a/packages/runtime/src/plugins/useFetchDebug.ts +++ b/packages/runtime/src/plugins/useFetchDebug.ts @@ -11,7 +11,7 @@ export function useFetchDebug< }, onFetch({ url, options, context }) { const fetchId = fetchAPI.crypto.randomUUID(); - const log = context.log.child({ fetchId }); + const log = context.log.child({ fetchId }, '[useFetchDebug] '); log.debug( () => ({ url, @@ -19,7 +19,7 @@ export function useFetchDebug< headers: options?.headers, signal: options?.signal?.aborted ? options?.signal?.reason : false, }), - 'http-fetch-request', + 'Request', ); const start = performance.now(); return function onFetchDone({ response }) { @@ -29,7 +29,7 @@ export function useFetchDebug< headers: Object.fromEntries(response.headers.entries()), duration: performance.now() - start, }), - 'http-fetch-response', + 'Response', ); }; }, diff --git a/packages/runtime/src/plugins/useRetryOnSchemaReload.ts b/packages/runtime/src/plugins/useRetryOnSchemaReload.ts index c6e70fe19..8dc8c8910 100644 --- a/packages/runtime/src/plugins/useRetryOnSchemaReload.ts +++ b/packages/runtime/src/plugins/useRetryOnSchemaReload.ts @@ -51,7 +51,7 @@ export function useRetryOnSchemaReload>({ ) : rootLog; log.info( - 'The operation has been aborted after the supergraph schema reloaded, retrying the operation...', + '[useRetryOnSchemaReload] The operation has been aborted after the supergraph schema reloaded, retrying the operation...', ); if (execHandler) { return handleMaybePromise(execHandler, (newResult) => diff --git a/packages/runtime/src/plugins/useSubgraphExecuteDebug.ts b/packages/runtime/src/plugins/useSubgraphExecuteDebug.ts index 88139aa50..135763525 100644 --- a/packages/runtime/src/plugins/useSubgraphExecuteDebug.ts +++ b/packages/runtime/src/plugins/useSubgraphExecuteDebug.ts @@ -11,9 +11,12 @@ export function useSubgraphExecuteDebug< fetchAPI = yoga.fetchAPI; }, onSubgraphExecute({ executionRequest }) { - const log = executionRequest.context?.log.child({ - subgraphExecuteId: fetchAPI.crypto.randomUUID(), - }); + const log = executionRequest.context?.log.child( + { + subgraphExecuteId: fetchAPI.crypto.randomUUID(), + }, + '[useSubgraphExecuteDebug] ', + ); if (!log) { throw new Error('Logger is not available in the execution context'); } @@ -29,25 +32,25 @@ export function useSubgraphExecuteDebug< logData['variables'] = executionRequest.variables; } return logData; - }, 'subgraph-execute-start'); + }, 'Start'); const start = performance.now(); return function onSubgraphExecuteDone({ result }) { if (isAsyncIterable(result)) { return { onNext({ result }) { - log.debug(result, 'subgraph-execute-next'); + log.debug(result, 'Next'); }, onEnd() { log.debug( () => ({ duration: performance.now() - start, }), - 'subgraph-execute-end', + 'End', ); }, }; } - log.debug(result, 'subgraph-execute-done'); + log.debug(result, 'Done'); return void 0; }; }, diff --git a/packages/runtime/src/plugins/useWebhooks.ts b/packages/runtime/src/plugins/useWebhooks.ts index b8d1d7201..b8ec433c1 100644 --- a/packages/runtime/src/plugins/useWebhooks.ts +++ b/packages/runtime/src/plugins/useWebhooks.ts @@ -12,6 +12,7 @@ export function useWebhooks({ log, pubsub, }: GatewayWebhooksPluginOptions): GatewayPlugin { + log = log.child('[useWebhooks] '); if (!pubsub) { throw new Error(`You must provide a pubsub instance to webhooks feature! Example: @@ -38,9 +39,9 @@ export function useWebhooks({ () => request.text(), function handleWebhookPayload(webhookPayload) { log.debug( - 'Emitted webhook request for %s: %s', + { payload: webhookPayload }, + 'Emitted webhook request for %s', pathname, - webhookPayload, ); webhookPayload = request.headers.get('content-type') === 'application/json' From 7ea0540e8fb696124a0958614faed9da3c7d313f Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Mon, 21 Apr 2025 18:30:59 +0200 Subject: [PATCH 132/157] log errors and more things --- .../fusion-runtime/src/unifiedGraphManager.ts | 26 ++++++++++++------- packages/runtime/src/createGatewayRuntime.ts | 2 +- packages/runtime/src/plugins/useCacheDebug.ts | 20 +++++++------- .../src/plugins/useDelegationPlanDebug.ts | 2 +- 4 files changed, 28 insertions(+), 22 deletions(-) diff --git a/packages/fusion-runtime/src/unifiedGraphManager.ts b/packages/fusion-runtime/src/unifiedGraphManager.ts index 75e9e05f9..abcd08e02 100644 --- a/packages/fusion-runtime/src/unifiedGraphManager.ts +++ b/packages/fusion-runtime/src/unifiedGraphManager.ts @@ -177,8 +177,8 @@ export class UnifiedGraphManager implements AsyncDisposable { }, (err) => { this.opts.transportContext.log.error( - 'Failed to poll Supergraph', err, + 'Failed to poll Supergraph', ); this.polling$ = undefined; }, @@ -191,13 +191,15 @@ export class UnifiedGraphManager implements AsyncDisposable { ); if (this.opts.transportContext.cache) { this.opts.transportContext.log.debug( - `Searching for Supergraph in cache under key "${UNIFIEDGRAPH_CACHE_KEY}"...`, + { key: UNIFIEDGRAPH_CACHE_KEY }, + 'Searching for Supergraph in cache...', ); this.initialUnifiedGraph$ = handleMaybePromise( () => this.opts.transportContext.cache?.get(UNIFIEDGRAPH_CACHE_KEY), (cachedUnifiedGraph) => { if (cachedUnifiedGraph) { this.opts.transportContext.log.debug( + { key: UNIFIEDGRAPH_CACHE_KEY }, 'Found Supergraph in cache', ); return this.handleLoadedUnifiedGraph(cachedUnifiedGraph, true); @@ -215,7 +217,10 @@ export class UnifiedGraphManager implements AsyncDisposable { () => this.initialUnifiedGraph$!, (v) => { this.initialUnifiedGraph$ = undefined; - this.opts.transportContext.log.debug('Initial Supergraph fetched'); + this.opts.transportContext.log.debug( + { key: UNIFIEDGRAPH_CACHE_KEY }, + 'Initial Supergraph fetched', + ); return v; }, ); @@ -264,12 +269,13 @@ export class UnifiedGraphManager implements AsyncDisposable { // NOTE: we default to 60s because Cloudflare KV TTL does not accept anything less 60; this.opts.transportContext.log.debug( - `Caching Supergraph with TTL ${ttl}s`, + { ttl, key: UNIFIEDGRAPH_CACHE_KEY }, + 'Caching Supergraph', ); - const logCacheSetError = (e: unknown) => { + const logCacheSetError = (err: unknown) => { this.opts.transportContext.log.debug( - `Unable to store Supergraph in cache under key "${UNIFIEDGRAPH_CACHE_KEY}" with TTL ${ttl}s`, - e, + { err, ttl, key: UNIFIEDGRAPH_CACHE_KEY }, + 'Unable to cache Supergraph', ); }; try { @@ -285,10 +291,10 @@ export class UnifiedGraphManager implements AsyncDisposable { } catch (e) { logCacheSetError(e); } - } catch (e) { + } catch (err: any) { this.opts.transportContext.log.error( + err, 'Failed to initiate caching of Supergraph', - e, ); } } @@ -369,8 +375,8 @@ export class UnifiedGraphManager implements AsyncDisposable { (err) => { this.disposeReason = undefined; this.opts.transportContext.log.error( - 'Failed to dispose the existing transports and executors', err, + 'Failed to dispose the existing transports and executors', ); return this.unifiedGraph!; }, diff --git a/packages/runtime/src/createGatewayRuntime.ts b/packages/runtime/src/createGatewayRuntime.ts index 0d2ec2188..ee5b8f688 100644 --- a/packages/runtime/src/createGatewayRuntime.ts +++ b/packages/runtime/src/createGatewayRuntime.ts @@ -1171,7 +1171,7 @@ export function createGatewayRuntime< useSubgraphExecuteDebug(), useFetchDebug(), useDelegationPlanDebug(), - useCacheDebug(), + useCacheDebug({ log: configContext.log }), ); } diff --git a/packages/runtime/src/plugins/useCacheDebug.ts b/packages/runtime/src/plugins/useCacheDebug.ts index 78de0c800..304ba8a74 100644 --- a/packages/runtime/src/plugins/useCacheDebug.ts +++ b/packages/runtime/src/plugins/useCacheDebug.ts @@ -1,18 +1,18 @@ import type { Logger } from '@graphql-hive/logger'; import { GatewayPlugin } from '../types'; -export function useCacheDebug< - TContext extends Record, ->(): GatewayPlugin { - let log: Logger; +export function useCacheDebug>({ + log: rootLog, +}: { + log: Logger; +}): GatewayPlugin { return { onContextBuilding({ context }) { - // TODO: this one should execute last - // TODO: on contextBuilding might not execute at all - log = context.log; + // onContextBuilding might not execute at all so we use the root log + rootLog = context.log; }, onCacheGet({ key }) { - log = log.child({ key }, '[useCacheDebug] '); + const log = rootLog.child({ key }, '[useCacheDebug] '); log.debug('Get'); return { onCacheGetError({ error }) { @@ -27,7 +27,7 @@ export function useCacheDebug< }; }, onCacheSet({ key, value, ttl }) { - log = log.child({ key, value, ttl }, '[useCacheDebug] '); + const log = rootLog.child({ key, value, ttl }, '[useCacheDebug] '); log.debug('Set'); return { onCacheSetError({ error }) { @@ -39,7 +39,7 @@ export function useCacheDebug< }; }, onCacheDelete({ key }) { - log = log.child({ key }, '[useCacheDebug] '); + const log = rootLog.child({ key }, '[useCacheDebug] '); log.debug('Delete'); return { onCacheDeleteError({ error }) { diff --git a/packages/runtime/src/plugins/useDelegationPlanDebug.ts b/packages/runtime/src/plugins/useDelegationPlanDebug.ts index a4808a8cc..9acc94281 100644 --- a/packages/runtime/src/plugins/useDelegationPlanDebug.ts +++ b/packages/runtime/src/plugins/useDelegationPlanDebug.ts @@ -94,7 +94,7 @@ export function useDelegationPlanDebug< subgraph, typeName, }; - const log = context.log.child(logMeta); + const log = context.log.child(logMeta, '[useDelegationPlanDebug] '); log.debug( () => ({ ...log, From 954bdf2f33b07c73004e6c6a70c620a09512a2a8 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Mon, 21 Apr 2025 18:55:56 +0200 Subject: [PATCH 133/157] legacylogger fot getincontext --- packages/runtime/src/createGatewayRuntime.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/runtime/src/createGatewayRuntime.ts b/packages/runtime/src/createGatewayRuntime.ts index ee5b8f688..43eaf2b67 100644 --- a/packages/runtime/src/createGatewayRuntime.ts +++ b/packages/runtime/src/createGatewayRuntime.ts @@ -641,7 +641,7 @@ export function createGatewayRuntime< unifiedGraph, // @ts-expect-error - Typings are wrong in legacy Mesh [subschemaConfig], - configContext.log, + LegacyLogger.from(configContext.log), onDelegateHooks, ), ); From 080026c588a0c6310da8ae81887cfc740cb620d2 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Mon, 21 Apr 2025 18:57:35 +0200 Subject: [PATCH 134/157] this ok --- packages/runtime/src/createGatewayRuntime.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/runtime/src/createGatewayRuntime.ts b/packages/runtime/src/createGatewayRuntime.ts index 43eaf2b67..5d457c674 100644 --- a/packages/runtime/src/createGatewayRuntime.ts +++ b/packages/runtime/src/createGatewayRuntime.ts @@ -1159,7 +1159,7 @@ export function createGatewayRuntime< if (config.logging === 'debug') { isDebug = true; } else { - // TODO: adding extra plugins in a logger is not a good idea, what if the writer is async? refactor + // we use the logger's debug option because the extra plugins only add more logs log.debug(() => { isDebug = true; return 'Debug mode enabled'; From f34267ba6345a412b89ed8e2548dcc1e5fa904be Mon Sep 17 00:00:00 2001 From: enisdenjo Date: Wed, 23 Apr 2025 15:15:14 +0200 Subject: [PATCH 135/157] explain potential placholders --- packages/logger/README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/logger/README.md b/packages/logger/README.md index 4c9d00241..d94c8cb12 100644 --- a/packages/logger/README.md +++ b/packages/logger/README.md @@ -157,6 +157,14 @@ Outputs: 2025-04-10T14:00:00.000Z INF hello world {"obj":true} 4 {"another":"obj"} ``` +Available interpolation placeholders are: + +- `%s` - string +- `%d` and `%f` - number with(out) decimals +- `%i` - integer number +- `%o`,`%O` and `%j` - JSON stringified object +- `%%` - escaped percentage sign + ## Logging Levels The default logger uses the `info` log level which will make sure to log only `info`+ logs. Available log levels are: From e9723620835a55a55159217496b0ce03c03aafa6 Mon Sep 17 00:00:00 2001 From: enisdenjo Date: Wed, 23 Apr 2025 22:25:38 +0200 Subject: [PATCH 136/157] warn on failing async writes right away --- packages/logger/src/logger.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/logger/src/logger.ts b/packages/logger/src/logger.ts index 6f0255f65..4d7b84a84 100644 --- a/packages/logger/src/logger.ts +++ b/packages/logger/src/logger.ts @@ -112,8 +112,9 @@ export class Logger implements AsyncDisposable { // we remove from pending writes only if the write was successful this.#pendingWrites.delete(write$); }) - .catch(() => { + .catch((e) => { // otherwise we keep in the pending write to throw on flush + console.error('Failed to write async log', e); }); } } From b47b06df0cf11698212654f7be714c4edfd9db2e Mon Sep 17 00:00:00 2001 From: enisdenjo Date: Wed, 23 Apr 2025 22:41:17 +0200 Subject: [PATCH 137/157] create pendingwrites set only when necessary --- packages/logger/src/logger.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/logger/src/logger.ts b/packages/logger/src/logger.ts index 4d7b84a84..e859e15d3 100644 --- a/packages/logger/src/logger.ts +++ b/packages/logger/src/logger.ts @@ -49,7 +49,7 @@ export class Logger implements AsyncDisposable { #prefix: string | undefined; #attrs: Attributes | undefined; #writers: [LogWriter, ...LogWriter[]]; - #pendingWrites = new Set>(); + #pendingWrites?: Set>; constructor(opts: LoggerOptions = {}) { let logLevelEnv = getEnv('LOG_LEVEL'); @@ -106,11 +106,12 @@ export class Logger implements AsyncDisposable { for (const w of this.#writers) { const write$ = w.write(level, attrs, msg); if (isPromise(write$)) { + this.#pendingWrites ??= new Set(); this.#pendingWrites.add(write$); write$ .then(() => { // we remove from pending writes only if the write was successful - this.#pendingWrites.delete(write$); + this.#pendingWrites!.delete(write$); }) .catch((e) => { // otherwise we keep in the pending write to throw on flush @@ -121,14 +122,14 @@ export class Logger implements AsyncDisposable { } public flush() { - if (this.#pendingWrites.size) { + if (this.#pendingWrites?.size) { const errs: unknown[] = []; return Promise.allSettled( Array.from(this.#pendingWrites).map((w) => w.catch((err) => errs.push(err)), ), ).then(() => { - this.#pendingWrites.clear(); + this.#pendingWrites!.clear(); if (errs.length) { throw new AggregateError( errs, From 7422683a45eab8e8b8037e3fb8fff7c1ec967af5 Mon Sep 17 00:00:00 2001 From: enisdenjo Date: Wed, 23 Apr 2025 22:44:21 +0200 Subject: [PATCH 138/157] hide console errorrs during async write test --- packages/logger/tests/logger.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/logger/tests/logger.test.ts b/packages/logger/tests/logger.test.ts index 18eb270bc..9722a6f27 100644 --- a/packages/logger/tests/logger.test.ts +++ b/packages/logger/tests/logger.test.ts @@ -315,6 +315,16 @@ it('should wait for async writers on flush', async () => { }); it('should handle async write errors on flush', async () => { + const origConsoleError = console.error; + console.error = vi.fn(() => { + // noop + }); + using _ = { + [Symbol.dispose]() { + console.error = origConsoleError; + }, + }; + let i = 0; const log = new Logger({ writers: [ @@ -344,6 +354,7 @@ it('should handle async write errors on flush', async () => { [Error: Write failed! #2], ] `); + expect(console.error).toHaveBeenCalledTimes(2); } }); From da28e1ba54e0b622f38964db1c0da16e3f408d8a Mon Sep 17 00:00:00 2001 From: enisdenjo Date: Wed, 23 Apr 2025 23:14:26 +0200 Subject: [PATCH 139/157] dont overwrite log instances --- packages/fusion-runtime/src/federation/supergraph.ts | 6 +++--- packages/plugins/hmac-upstream-signature/src/index.ts | 9 +++++++-- packages/runtime/src/plugins/useWebhooks.ts | 4 ++-- packages/transports/http-callback/src/index.ts | 6 +++--- packages/transports/ws/src/index.ts | 4 ++-- 5 files changed, 17 insertions(+), 12 deletions(-) diff --git a/packages/fusion-runtime/src/federation/supergraph.ts b/packages/fusion-runtime/src/federation/supergraph.ts index 2fe830e5e..94d5aa5f6 100644 --- a/packages/fusion-runtime/src/federation/supergraph.ts +++ b/packages/fusion-runtime/src/federation/supergraph.ts @@ -158,7 +158,7 @@ export const handleFederationSupergraph: UnifiedGraphHandler = function ({ onDelegateHooks, additionalTypeDefs: additionalTypeDefsFromConfig = [], additionalResolvers: additionalResolversFromConfig = [], - log, + log: rootLog, }: UnifiedGraphHandlerOpts): UnifiedGraphHandlerResult { const additionalTypeDefs = [...asArray(additionalTypeDefsFromConfig)]; const additionalResolvers = [...asArray(additionalResolversFromConfig)]; @@ -230,7 +230,7 @@ export const handleFederationSupergraph: UnifiedGraphHandler = function ({ originalResolver, typeName, onDelegationStageExecuteHooks, - log, + rootLog, ); } } @@ -278,7 +278,7 @@ export const handleFederationSupergraph: UnifiedGraphHandler = function ({ executableUnifiedGraph, // @ts-expect-error Legacy Mesh RawSource is not compatible with new Mesh subschemas, - LegacyLogger.from(log), + LegacyLogger.from(rootLog), onDelegateHooks || [], ); const stitchingInfo = executableUnifiedGraph.extensions?.[ diff --git a/packages/plugins/hmac-upstream-signature/src/index.ts b/packages/plugins/hmac-upstream-signature/src/index.ts index 5a6dc75c0..71ad25246 100644 --- a/packages/plugins/hmac-upstream-signature/src/index.ts +++ b/packages/plugins/hmac-upstream-signature/src/index.ts @@ -93,8 +93,13 @@ export function useHmacUpstreamSignature( onYogaInit({ yoga }) { fetchAPI = yoga.fetchAPI; }, - onSubgraphExecute({ subgraphName, subgraph, executionRequest, log }) { - log = log.child('[useHmacUpstreamSignature] '); + onSubgraphExecute({ + subgraphName, + subgraph, + executionRequest, + log: rootLog, + }) { + const log = rootLog.child('[useHmacUpstreamSignature] '); log.debug('Running shouldSign for subgraph %s', subgraphName); if (shouldSign({ subgraphName, subgraph, executionRequest })) { diff --git a/packages/runtime/src/plugins/useWebhooks.ts b/packages/runtime/src/plugins/useWebhooks.ts index b8ec433c1..deab67092 100644 --- a/packages/runtime/src/plugins/useWebhooks.ts +++ b/packages/runtime/src/plugins/useWebhooks.ts @@ -9,10 +9,10 @@ export interface GatewayWebhooksPluginOptions { } export function useWebhooks({ - log, + log: rootLog, pubsub, }: GatewayWebhooksPluginOptions): GatewayPlugin { - log = log.child('[useWebhooks] '); + const log = rootLog.child('[useWebhooks] '); if (!pubsub) { throw new Error(`You must provide a pubsub instance to webhooks feature! Example: diff --git a/packages/transports/http-callback/src/index.ts b/packages/transports/http-callback/src/index.ts index 69ec88a76..a4dda7f95 100644 --- a/packages/transports/http-callback/src/index.ts +++ b/packages/transports/http-callback/src/index.ts @@ -73,7 +73,7 @@ export default { transportEntry, fetch, pubsub, - log, + log: rootLog, }): DisposableExecutor { let headersInConfig: Record | undefined; if (typeof transportEntry.headers === 'string') { @@ -106,7 +106,7 @@ export default { executionRequest: ExecutionRequest, ) { const subscriptionId = crypto.randomUUID(); - log = log.child({ + const log = rootLog.child({ executor: 'http-callback', subscription: subscriptionId, }); @@ -226,7 +226,7 @@ export default { }, ), (e) => { - log.debug(e, `Subscription request failed`); + log.error(e, 'Subscription request failed'); stopSubscription(e); }, ); diff --git a/packages/transports/ws/src/index.ts b/packages/transports/ws/src/index.ts index 0ab2ccf7b..22a42fe48 100644 --- a/packages/transports/ws/src/index.ts +++ b/packages/transports/ws/src/index.ts @@ -33,7 +33,7 @@ export interface WSTransportOptions { export default { getSubgraphExecutor( - { transportEntry, log }, + { transportEntry, log: rootLog }, /** * Do not use this option unless you know what you are doing. * @internal @@ -76,7 +76,7 @@ export default { let wsExecutor = wsExecutorMap.get(hash); if (!wsExecutor) { - log = log.child({ + const log = rootLog.child({ executor: 'GraphQL WS', wsUrl, connectionParams, From 57e5c8b39c3207ed98e738259455ecb5e2d27b1f Mon Sep 17 00:00:00 2001 From: enisdenjo Date: Thu, 24 Apr 2025 12:41:17 +0200 Subject: [PATCH 140/157] nodejs util inspect custom symol --- packages/logger/src/utils.ts | 15 +++++++++++++++ packages/logger/tests/logger.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/packages/logger/src/utils.ts b/packages/logger/src/utils.ts index 7476f400f..fbfaae167 100644 --- a/packages/logger/src/utils.ts +++ b/packages/logger/src/utils.ts @@ -128,6 +128,8 @@ function isPrimitive(val: unknown): val is string | number | boolean { return val !== Object(val); } +const nodejsCustomInspectSy = Symbol.for('nodejs.util.inspect.custom'); + function objectifyClass(val: unknown): Record { if ( // simply empty @@ -145,6 +147,19 @@ function objectifyClass(val: unknown): Record { // if the object has a toJSON method, use it - always return val.toJSON(); } + if ( + typeof val === 'object' && + nodejsCustomInspectSy in val && + typeof val[nodejsCustomInspectSy] === 'function' + ) { + // > Custom [util.inspect.custom](depth, opts, inspect) functions typically return a string but may return a value of any type that will be formatted accordingly by util.inspect(). + return { + [nodejsCustomInspectSy.toString()]: unwrapAttrVal( + val[nodejsCustomInspectSy](Infinity, {}), + ), + class: val.constructor.name, + }; + } const props: Record = {}; for (const propName of Object.getOwnPropertyNames(val)) { props[propName] = unwrapAttrVal(val[propName as keyof typeof val]); diff --git a/packages/logger/tests/logger.test.ts b/packages/logger/tests/logger.test.ts index 9722a6f27..d98ca15c3 100644 --- a/packages/logger/tests/logger.test.ts +++ b/packages/logger/tests/logger.test.ts @@ -784,3 +784,28 @@ it('should change child log level only on child', () => { ] `); }); + +it('should log using nodejs.util.inspect.custom symbol', () => { + const [log, writer] = createTLogger(); + + class Inspect { + [Symbol.for('nodejs.util.inspect.custom')]() { + return 'Ok good'; + } + } + + log.info(new Inspect(), 'sy'); + + expect(writer.logs).toMatchInlineSnapshot(` + [ + { + "attrs": { + "Symbol(nodejs.util.inspect.custom)": "Ok good", + "class": "Inspect", + }, + "level": "info", + "msg": "sy", + }, + ] + `); +}); From a321bca9a32a2a029b5d493c4657fa51b98d0426 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Tue, 20 May 2025 20:16:56 +0200 Subject: [PATCH 141/157] better child --- packages/gateway/src/commands/proxy.ts | 3 +-- packages/runtime/src/createGatewayRuntime.ts | 7 ++++--- packages/runtime/src/fetchers/graphos.ts | 4 ++-- packages/runtime/src/getReportingPlugin.ts | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/gateway/src/commands/proxy.ts b/packages/gateway/src/commands/proxy.ts index ba099ad59..362c42aa1 100644 --- a/packages/gateway/src/commands/proxy.ts +++ b/packages/gateway/src/commands/proxy.ts @@ -69,11 +69,10 @@ export const addCommand: AddCommand = (ctx, cli) => const hiveCdnEndpointOpt = // TODO: take schema from optsWithGlobals once https://github.com/commander-js/extra-typings/pull/76 is merged this.opts().schema || hiveCdnEndpoint; - const hiveCdnLogger = ctx.log.child({ source: 'Hive CDN' }); if (hiveCdnEndpointOpt) { if (hiveCdnKey) { if (!isUrl(hiveCdnEndpointOpt)) { - hiveCdnLogger.error( + ctx.log.error( 'Endpoint must be a URL when providing --hive-cdn-key but got ' + hiveCdnEndpointOpt, ); diff --git a/packages/runtime/src/createGatewayRuntime.ts b/packages/runtime/src/createGatewayRuntime.ts index 5d457c674..008fc62f7 100644 --- a/packages/runtime/src/createGatewayRuntime.ts +++ b/packages/runtime/src/createGatewayRuntime.ts @@ -282,7 +282,7 @@ export function createGatewayRuntime< endpoint, key, logger: LegacyLogger.from( - configContext.log.child({ source: 'Hive CDN' }), + configContext.log.child('[hiveSchemaFetcher] '), ), }); schemaFetcher = function fetchSchemaFromCDN() { @@ -675,7 +675,8 @@ export function createGatewayRuntime< const fetcher = createSupergraphSDLFetcher({ endpoint, key, - log: configContext.log.child({ source: 'Hive CDN' }), + log: configContext.log.child('[hiveSupergraphFetcher] '), + // @ts-expect-error - MeshFetch is not compatible with `typeof fetch` fetchImplementation: configContext.fetch, }); @@ -753,7 +754,7 @@ export function createGatewayRuntime< }); getSchema = () => unifiedGraphManager.getUnifiedGraph(); readinessChecker = () => { - const log = configContext.log.child('readiness'); + const log = configContext.log.child('[readiness] '); log.debug('checking'); return handleMaybePromise( () => unifiedGraphManager.getUnifiedGraph(), diff --git a/packages/runtime/src/fetchers/graphos.ts b/packages/runtime/src/fetchers/graphos.ts index 6cd18f20f..f9b58122e 100644 --- a/packages/runtime/src/fetchers/graphos.ts +++ b/packages/runtime/src/fetchers/graphos.ts @@ -56,8 +56,8 @@ export function createGraphOSFetcher({ graphosOpts.upLink || process.env['APOLLO_SCHEMA_CONFIG_DELIVERY_ENDPOINT']; const uplinks = uplinksParam?.split(',').map((uplink) => uplink.trim()) || DEFAULT_UPLINKS; - const log = configContext.log.child({ source: 'GraphOS' }); - log.info({ uplinks }, 'Using GraphOS with uplinks'); + const log = configContext.log.child('[apolloGraphOSSupergraphFetcher] '); + log.info({ uplinks }, 'Using uplinks'); let supergraphLoadedPlace = defaultLoadedPlacePrefix; if (graphosOpts.graphRef) { supergraphLoadedPlace += `
${graphosOpts.graphRef}`; diff --git a/packages/runtime/src/getReportingPlugin.ts b/packages/runtime/src/getReportingPlugin.ts index e1bd85ba8..8540f7bde 100644 --- a/packages/runtime/src/getReportingPlugin.ts +++ b/packages/runtime/src/getReportingPlugin.ts @@ -30,7 +30,7 @@ export function getReportingPlugin>( return { name: 'Hive', plugin: useHiveConsole({ - log: configContext.log.child('Reporting with Hive'), + log: configContext.log.child('[useHiveConsole] '), enabled: true, ...reporting, ...(usage ? { usage } : {}), From acc22071e0897e1b52838a9d1a0df3e724c2069e Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Tue, 20 May 2025 20:22:27 +0200 Subject: [PATCH 142/157] debug mode enabled --- packages/runtime/src/createGatewayRuntime.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/runtime/src/createGatewayRuntime.ts b/packages/runtime/src/createGatewayRuntime.ts index 008fc62f7..8586c6235 100644 --- a/packages/runtime/src/createGatewayRuntime.ts +++ b/packages/runtime/src/createGatewayRuntime.ts @@ -1163,8 +1163,7 @@ export function createGatewayRuntime< // we use the logger's debug option because the extra plugins only add more logs log.debug(() => { isDebug = true; - return 'Debug mode enabled'; - }); + }, 'Debug mode enabled'); } if (isDebug) { From a7e5689d589a06209101a5ed3df07d94a9a52cbe Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Tue, 20 May 2025 20:23:45 +0200 Subject: [PATCH 143/157] better child --- packages/plugins/opentelemetry/src/plugin.ts | 6 +----- packages/runtime/src/createGatewayRuntime.ts | 4 +--- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/plugins/opentelemetry/src/plugin.ts b/packages/plugins/opentelemetry/src/plugin.ts index aafbc4ce2..343d123c7 100644 --- a/packages/plugins/opentelemetry/src/plugin.ts +++ b/packages/plugins/opentelemetry/src/plugin.ts @@ -313,11 +313,7 @@ export function useOpenTelemetry( const logger = createDeferred(); let pluginLogger = options.log - ? fakePromise( - options.log.child({ - plugin: 'OpenTelemetry', - }), - ) + ? fakePromise(options.log.child('[useOpenTelemetry] ')) : logger.promise; function init(): Promise { diff --git a/packages/runtime/src/createGatewayRuntime.ts b/packages/runtime/src/createGatewayRuntime.ts index 8586c6235..9029a7f3e 100644 --- a/packages/runtime/src/createGatewayRuntime.ts +++ b/packages/runtime/src/createGatewayRuntime.ts @@ -214,9 +214,7 @@ export function createGatewayRuntime< persistedDocumentsPlugin = useHiveConsole({ ...configContext, enabled: false, // disables only usage reporting - log: configContext.log.child({ - plugin: 'Hive Persisted Documents', - }), + log: configContext.log.child('[useHiveConsole.persistedDocuments] '), experimental__persistedDocuments: { cdn: { endpoint: config.persistedDocuments.endpoint, From 118de566f0e785a76ee7c271e682da02f877af5a Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Tue, 20 May 2025 20:26:06 +0200 Subject: [PATCH 144/157] ensure signal --- packages/runtime/src/plugins/useUpstreamCancel.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/runtime/src/plugins/useUpstreamCancel.ts b/packages/runtime/src/plugins/useUpstreamCancel.ts index 556ffe15e..04ed514fc 100644 --- a/packages/runtime/src/plugins/useUpstreamCancel.ts +++ b/packages/runtime/src/plugins/useUpstreamCancel.ts @@ -6,7 +6,7 @@ export function useUpstreamCancel(): GatewayPlugin { return { onFetch({ context, options, executionRequest, info }) { const signals: AbortSignal[] = []; - if ('request' in context) { + if ('request' in context && context.request.signal) { signals.push(context.request.signal); } const execRequestSignal = From 4e0a76f970b95bf318707a57c7d301f9025f14b3 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 22 May 2025 17:03:41 +0200 Subject: [PATCH 145/157] changesets --- .changeset/itchy-ways-cross.md | 19 +++++++++++++++++++ .changeset/tough-elephants-shop.md | 2 ++ 2 files changed, 21 insertions(+) create mode 100644 .changeset/itchy-ways-cross.md diff --git a/.changeset/itchy-ways-cross.md b/.changeset/itchy-ways-cross.md new file mode 100644 index 000000000..331529560 --- /dev/null +++ b/.changeset/itchy-ways-cross.md @@ -0,0 +1,19 @@ +--- +'@graphql-mesh/hmac-upstream-signature': major +'@graphql-mesh/transport-http-callback': major +'@graphql-mesh/plugin-opentelemetry': major +'@graphql-mesh/plugin-prometheus': major +'@graphql-mesh/transport-common': major +'@graphql-mesh/plugin-jwt-auth': major +'@graphql-mesh/fusion-runtime': major +'@graphql-mesh/transport-ws': major +'@graphql-hive/gateway': major +'@graphql-hive/gateway-runtime': major +'@graphql-hive/nestjs': major +--- + +Introduce and use the new Hive Logger + +- [Read more about it on the Hive Logger documentation here.](https://the-guild.dev/graphql/hive/docs/logger) + +- If coming from Hive Gateway v1, [read the migration guide here.](https://the-guild.dev/graphql/hive/docs/migration-guides/gateway-v1-v2) diff --git a/.changeset/tough-elephants-shop.md b/.changeset/tough-elephants-shop.md index 8f34ed113..2c628b131 100644 --- a/.changeset/tough-elephants-shop.md +++ b/.changeset/tough-elephants-shop.md @@ -3,3 +3,5 @@ --- Introducing Hive Logger + +[Read more about it on the Hive Logger documentation website.](https://the-guild.dev/graphql/hive/docs/logger) From 68c78631f0b17c31074cfebb2002513c5e385df9 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 22 May 2025 17:30:40 +0200 Subject: [PATCH 146/157] rootvalue execution request --- packages/fusion-runtime/src/utils.ts | 2 +- packages/runtime/src/plugins/useUpstreamRetry.ts | 5 +---- packages/runtime/src/wrapFetchWithHooks.ts | 6 +++++- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/fusion-runtime/src/utils.ts b/packages/fusion-runtime/src/utils.ts index 3d5d17df2..09b597896 100644 --- a/packages/fusion-runtime/src/utils.ts +++ b/packages/fusion-runtime/src/utils.ts @@ -300,7 +300,7 @@ export function wrapExecutorWithHooks({ baseExecutionRequest.info = baseExecutionRequest.info || ({} as GraphQLResolveInfo); baseExecutionRequest.info.executionRequest = baseExecutionRequest; - // TODO: Fix this in onFetch hook handler of @graphql-mesh/utils + // this rootValue will be set in the info value for field.resolvers in non-graphql requests // TODO: Also consider if a subgraph can ever rely on the gateway's rootValue? baseExecutionRequest.rootValue = { executionRequest: baseExecutionRequest, diff --git a/packages/runtime/src/plugins/useUpstreamRetry.ts b/packages/runtime/src/plugins/useUpstreamRetry.ts index ee0043c84..7ef434d45 100644 --- a/packages/runtime/src/plugins/useUpstreamRetry.ts +++ b/packages/runtime/src/plugins/useUpstreamRetry.ts @@ -182,11 +182,8 @@ export function useUpstreamRetry>( } } }, - onFetch({ info, executionRequest }) { - // if there's no execution request, it's a subgraph request + onFetch({ executionRequest }) { // TODO: Also consider what happens when there are multiple fetch calls for a single subgraph request - // @ts-expect-error - we know that it might have executionRequest property - executionRequest ||= info?.rootValue?.executionRequest; if (executionRequest) { return function onFetchDone({ response }) { executionRequestResponseMap.set(executionRequest, response); diff --git a/packages/runtime/src/wrapFetchWithHooks.ts b/packages/runtime/src/wrapFetchWithHooks.ts index 328f8e2e7..a8f27249f 100644 --- a/packages/runtime/src/wrapFetchWithHooks.ts +++ b/packages/runtime/src/wrapFetchWithHooks.ts @@ -45,7 +45,11 @@ export function wrapFetchWithHooks( // @ts-expect-error TODO: why? info, get executionRequest() { - return info?.executionRequest; + return ( + info?.executionRequest || + // @ts-expect-error might be in the root value, see packages/fusion-runtime/src/utils.ts + info?.rootValue?.executionRequest + ); }, endResponse(newResponse) { response$ = newResponse; From 69ec0415c7261a97e2d016d5c584586f10a8e62e Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 22 May 2025 17:36:06 +0200 Subject: [PATCH 147/157] use log instead of logger --- packages/plugins/opentelemetry/src/plugin.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/plugins/opentelemetry/src/plugin.ts b/packages/plugins/opentelemetry/src/plugin.ts index 343d123c7..8b5977d2d 100644 --- a/packages/plugins/opentelemetry/src/plugin.ts +++ b/packages/plugins/opentelemetry/src/plugin.ts @@ -396,7 +396,7 @@ export function useOpenTelemetry( ); if (!useContextManager) { if (options.spans?.schema) { - logger.warn( + log.warn( 'Schema loading spans are disabled because no context manager is available', ); } From 4dd6a7797ceba83e2e63b4640e1ede64ff9de6f9 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 22 May 2025 19:34:23 +0200 Subject: [PATCH 148/157] bench logger msg --- packages/logger/tests/logger.bench.ts | 58 +++++++++++++++++++++++++++ vitest.projects.ts | 6 ++- 2 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 packages/logger/tests/logger.bench.ts diff --git a/packages/logger/tests/logger.bench.ts b/packages/logger/tests/logger.bench.ts new file mode 100644 index 000000000..bd4a3eefa --- /dev/null +++ b/packages/logger/tests/logger.bench.ts @@ -0,0 +1,58 @@ +import { jsonStringify, Logger } from '@graphql-hive/logger'; +import { bench, describe } from 'vitest'; + +const voidlog = new Logger({ + writers: [ + { + write() { + // void + }, + }, + ], +}); + +describe.each([ + { name: 'string' as const, value: 'hello' }, + { name: 'integer' as const, value: 7 }, + { name: 'float' as const, value: 7.77 }, + { name: 'object' as const, value: { hello: 'world' } }, +])('log formatting of $name', ({ name, value }) => { + // we switch outside of the bench to avoid the overhead of the switch + switch (name) { + case 'string': + bench('template literals', () => { + voidlog.info(`hi there ${value}`); + }); + bench('interpolation', () => { + voidlog.info('hi there %s', value); + }); + break; + case 'integer': + bench('template literals', () => { + voidlog.info(`hi there ${value}`); + }); + bench('interpolation', () => { + voidlog.info('hi there %i', value); + }); + break; + case 'float': + bench('template literals', () => { + voidlog.info(`hi there ${value}`); + }); + bench('interpolation', () => { + voidlog.info('hi there %d', value); + }); + break; + case 'object': + bench('template literals native stringify', () => { + voidlog.info(`hi there ${JSON.stringify(value)}`); + }); + bench('template literals logger stringify', () => { + voidlog.info(`hi there ${jsonStringify(value)}`); + }); + bench('interpolation', () => { + voidlog.info('hi there %o', value); + }); + break; + } +}); diff --git a/vitest.projects.ts b/vitest.projects.ts index 4dd6d516f..33180dd9d 100644 --- a/vitest.projects.ts +++ b/vitest.projects.ts @@ -40,7 +40,11 @@ export default defineWorkspace([ hookTimeout: testTimeout, testTimeout, benchmark: { - include: ['bench/**/*.bench.ts', 'e2e/**/*.bench.ts'], + include: [ + 'bench/**/*.bench.ts', + 'e2e/**/*.bench.ts', + '**/tests/**/*.bench.ts', + ], reporters: ['verbose'], outputJson: 'bench/results.json', }, From 4d99d760df9727da9e32e438f0a02003fb427661 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 22 May 2025 19:44:43 +0200 Subject: [PATCH 149/157] test console writer in bun too --- .../tests/__snapshots__/writers.test.ts.snap | 97 +++---------------- packages/logger/tests/writers.test.ts | 25 +++-- 2 files changed, 22 insertions(+), 100 deletions(-) diff --git a/packages/logger/tests/__snapshots__/writers.test.ts.snap b/packages/logger/tests/__snapshots__/writers.test.ts.snap index 9262158b0..7b0ee8652 100644 --- a/packages/logger/tests/__snapshots__/writers.test.ts.snap +++ b/packages/logger/tests/__snapshots__/writers.test.ts.snap @@ -2,96 +2,21 @@ exports[`ConsoleLogWriter should color levels and keys 1`] = ` [ - "TRC hi - hello: { - dear: "world" - try: [ - "num" - 1 - 2 - ] - }", - "DBG hi - hello: { - dear: "world" - try: [ - "num" - 1 - 2 - ] - }", - "INF hi - hello: { - dear: "world" - try: [ - "num" - 1 - 2 - ] - }", - "WRN hi - hello: { - dear: "world" - try: [ - "num" - 1 - 2 - ] - }", - "ERR hi - hello: { - dear: "world" - try: [ - "num" - 1 - 2 - ] - }", + ""\\u001b[36mTRC\\u001b[0m \\u001b[1mhi\\u001b[0m \\n \\u001b[35mhello:\\u001b[0m \\u001b[35m{\\u001b[0m\\n \\u001b[35mdear:\\u001b[0m \\"world\\"\\n \\u001b[35mtry:\\u001b[0m \\u001b[35m[\\u001b[0m\\n \\"num\\"\\n 1\\n 2\\n \\u001b[35m]\\u001b[0m\\n \\u001b[35m}\\u001b[0m"", + ""\\u001b[90mDBG\\u001b[0m \\u001b[1mhi\\u001b[0m \\n \\u001b[35mhello:\\u001b[0m \\u001b[35m{\\u001b[0m\\n \\u001b[35mdear:\\u001b[0m \\"world\\"\\n \\u001b[35mtry:\\u001b[0m \\u001b[35m[\\u001b[0m\\n \\"num\\"\\n 1\\n 2\\n \\u001b[35m]\\u001b[0m\\n \\u001b[35m}\\u001b[0m"", + ""\\u001b[32mINF\\u001b[0m \\u001b[1mhi\\u001b[0m \\n \\u001b[35mhello:\\u001b[0m \\u001b[35m{\\u001b[0m\\n \\u001b[35mdear:\\u001b[0m \\"world\\"\\n \\u001b[35mtry:\\u001b[0m \\u001b[35m[\\u001b[0m\\n \\"num\\"\\n 1\\n 2\\n \\u001b[35m]\\u001b[0m\\n \\u001b[35m}\\u001b[0m"", + ""\\u001b[33mWRN\\u001b[0m \\u001b[1mhi\\u001b[0m \\n \\u001b[35mhello:\\u001b[0m \\u001b[35m{\\u001b[0m\\n \\u001b[35mdear:\\u001b[0m \\"world\\"\\n \\u001b[35mtry:\\u001b[0m \\u001b[35m[\\u001b[0m\\n \\"num\\"\\n 1\\n 2\\n \\u001b[35m]\\u001b[0m\\n \\u001b[35m}\\u001b[0m"", + ""\\u001b[41;39mERR\\u001b[0m \\u001b[1mhi\\u001b[0m \\n \\u001b[35mhello:\\u001b[0m \\u001b[35m{\\u001b[0m\\n \\u001b[35mdear:\\u001b[0m \\"world\\"\\n \\u001b[35mtry:\\u001b[0m \\u001b[35m[\\u001b[0m\\n \\"num\\"\\n 1\\n 2\\n \\u001b[35m]\\u001b[0m\\n \\u001b[35m}\\u001b[0m"", ] `; exports[`ConsoleLogWriter should pretty print the attributes 1`] = ` [ - "TRC obj - a: 1 - b: 2", - "DBG arr - "a" - "b" - "c"", - "INF nested - a: { - b: { - c: { - d: 1 - } - } - }", - "WRN arr objs - { - a: 1 - } - { - b: 2 - }", - "ERR multlinestring - str: "a - b - c" - err: { - stack: "" - message: "woah!" - name: "Error" - class: "Error" - }", - "INF graphql - query: " - { - hi(howMany: 1) { - hello - world - } - } - "", + ""TRC obj \\n a: 1\\n b: 2"", + ""DBG arr \\n \\"a\\"\\n \\"b\\"\\n \\"c\\""", + ""INF nested \\n a: {\\n b: {\\n c: {\\n d: 1\\n }\\n }\\n }"", + ""WRN arr objs \\n {\\n a: 1\\n }\\n {\\n b: 2\\n }"", + ""ERR multlinestring \\n str: \\"a\\n b\\n c\\"\\n err: {\\n message: \\"woah!\\"\\n }"", + ""INF graphql \\n query: \\"\\n {\\n hi(howMany: 1) {\\n hello\\n world\\n }\\n }\\n \\""", ] `; diff --git a/packages/logger/tests/writers.test.ts b/packages/logger/tests/writers.test.ts index 1869f5c9d..d3ae6227a 100644 --- a/packages/logger/tests/writers.test.ts +++ b/packages/logger/tests/writers.test.ts @@ -1,27 +1,27 @@ import { describe, expect, it } from 'vitest'; import { Logger } from '../src/logger'; -import { ConsoleLogWriter, ConsoleLogWriterOptions } from '../src/writers'; -import { stableError } from './utils'; +import { + ConsoleLogWriter, + ConsoleLogWriterOptions, + jsonStringify, +} from '../src/writers'; -describe.skipIf( - // bun is serialising the snapshots differently. the object keys are out of order... - globalThis.Bun, -)('ConsoleLogWriter', () => { +describe('ConsoleLogWriter', () => { function createTConsoleLogger(opts?: Partial) { const logs: string[] = []; const writer = new ConsoleLogWriter({ console: { debug: (...args: unknown[]) => { - logs.push(args.join(' ')); + logs.push(args.map((arg) => jsonStringify(arg)).join(' ')); }, info: (...args: unknown[]) => { - logs.push(args.join(' ')); + logs.push(args.map((arg) => jsonStringify(arg)).join(' ')); }, warn: (...args: unknown[]) => { - logs.push(args.join(' ')); + logs.push(args.map((arg) => jsonStringify(arg)).join(' ')); }, error: (...args: unknown[]) => { - logs.push(args.join(' ')); + logs.push(args.map((arg) => jsonStringify(arg)).join(' ')); }, }, noTimestamp: true, @@ -38,10 +38,7 @@ describe.skipIf( log.debug(['a', 'b', 'c'], 'arr'); log.info({ a: { b: { c: { d: 1 } } } }, 'nested'); log.warn([{ a: 1 }, { b: 2 }], 'arr objs'); - log.error( - { str: 'a\nb\nc', err: stableError(new Error('woah!')) }, - 'multlinestring', - ); + log.error({ str: 'a\nb\nc', err: { message: 'woah!' } }, 'multlinestring'); log.info( { From 9faced084900726df8dc44ada04da5fcfb82bea3 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Mon, 26 May 2025 19:55:18 +0200 Subject: [PATCH 150/157] transportcontext is not required --- packages/fusion-runtime/src/executor.ts | 6 +-- .../src/federation/supergraph.ts | 3 +- .../fusion-runtime/src/unifiedGraphManager.ts | 45 ++++++++-------- packages/fusion-runtime/src/utils.ts | 51 +++++++++++-------- packages/fusion-runtime/tests/polling.test.ts | 6 --- packages/fusion-runtime/tests/utils.ts | 5 -- 6 files changed, 59 insertions(+), 57 deletions(-) diff --git a/packages/fusion-runtime/src/executor.ts b/packages/fusion-runtime/src/executor.ts index 870ce38dd..094aca8a4 100644 --- a/packages/fusion-runtime/src/executor.ts +++ b/packages/fusion-runtime/src/executor.ts @@ -34,7 +34,7 @@ export function getExecutorForUnifiedGraph( () => unifiedGraphManager.getContext(execReq.context), (context) => { function handleExecutor(executor: Executor) { - opts?.transportContext.log.debug( + opts?.transportContext?.log.debug( 'Executing request on unified graph', () => print(execReq.document), ); @@ -50,7 +50,7 @@ export function getExecutorForUnifiedGraph( return handleMaybePromise( () => unifiedGraphManager.getUnifiedGraph(), (unifiedGraph) => { - opts?.transportContext.log.debug( + opts?.transportContext?.log.debug( 'Executing request on unified graph', () => print(execReq.document), ); @@ -70,7 +70,7 @@ export function getExecutorForUnifiedGraph( enumerable: true, get() { return function unifiedGraphExecutorDispose() { - opts?.transportContext.log.debug('Disposing unified graph executor'); + opts?.transportContext?.log.debug('Disposing unified graph executor'); return unifiedGraphManager[DisposableSymbols.asyncDispose](); }; }, diff --git a/packages/fusion-runtime/src/federation/supergraph.ts b/packages/fusion-runtime/src/federation/supergraph.ts index 94d5aa5f6..ec33091e9 100644 --- a/packages/fusion-runtime/src/federation/supergraph.ts +++ b/packages/fusion-runtime/src/federation/supergraph.ts @@ -158,7 +158,8 @@ export const handleFederationSupergraph: UnifiedGraphHandler = function ({ onDelegateHooks, additionalTypeDefs: additionalTypeDefsFromConfig = [], additionalResolvers: additionalResolversFromConfig = [], - log: rootLog, + // no logger was provided, use a muted logger for consistency across plugin hooks + log: rootLog = new Logger({ level: false }), }: UnifiedGraphHandlerOpts): UnifiedGraphHandlerResult { const additionalTypeDefs = [...asArray(additionalTypeDefsFromConfig)]; const additionalResolvers = [...asArray(additionalResolversFromConfig)]; diff --git a/packages/fusion-runtime/src/unifiedGraphManager.ts b/packages/fusion-runtime/src/unifiedGraphManager.ts index abcd08e02..7330a18ef 100644 --- a/packages/fusion-runtime/src/unifiedGraphManager.ts +++ b/packages/fusion-runtime/src/unifiedGraphManager.ts @@ -69,7 +69,7 @@ export interface UnifiedGraphHandlerOpts { onDelegationPlanHooks?: OnDelegationPlanHook[]; onDelegationStageExecuteHooks?: OnDelegationStageExecuteHook[]; onDelegateHooks?: OnDelegateHook[]; - log: Logger; + log?: Logger; } export interface UnifiedGraphHandlerResult { @@ -81,7 +81,7 @@ export interface UnifiedGraphHandlerResult { export interface UnifiedGraphManagerOptions { getUnifiedGraph( - ctx: TransportContext, + ctx: TransportContext | undefined, ): MaybePromise; // Handle the unified graph by any specification handleUnifiedGraph?: UnifiedGraphHandler; @@ -93,7 +93,7 @@ export interface UnifiedGraphManagerOptions { additionalResolvers?: | IResolvers | IResolvers[]; - transportContext: TransportContext; + transportContext?: TransportContext; onSubgraphExecuteHooks?: OnSubgraphExecuteHook[]; // TODO: Will be removed later once we get rid of v0 onDelegateHooks?: OnDelegateHook[]; @@ -156,7 +156,7 @@ export class UnifiedGraphManager implements AsyncDisposable { this.onDelegationStageExecuteHooks = opts?.onDelegationStageExecuteHooks || []; if (opts.pollingInterval != null) { - opts.transportContext.log.debug( + opts.transportContext?.log.debug( `Starting polling to Supergraph with interval ${millisecondsToStr(opts.pollingInterval)}`, ); } @@ -169,14 +169,14 @@ export class UnifiedGraphManager implements AsyncDisposable { this.lastLoadTime != null && Date.now() - this.lastLoadTime >= this.opts.pollingInterval ) { - this.opts?.transportContext.log.debug(`Polling Supergraph`); + this.opts?.transportContext?.log.debug(`Polling Supergraph`); this.polling$ = handleMaybePromise( () => this.getAndSetUnifiedGraph(), () => { this.polling$ = undefined; }, (err) => { - this.opts.transportContext.log.error( + this.opts.transportContext?.log.error( err, 'Failed to poll Supergraph', ); @@ -186,19 +186,20 @@ export class UnifiedGraphManager implements AsyncDisposable { } if (!this.unifiedGraph) { if (!this.initialUnifiedGraph$) { - this.opts?.transportContext.log.debug( + this.opts?.transportContext?.log.debug( 'Fetching the initial Supergraph', ); - if (this.opts.transportContext.cache) { - this.opts.transportContext.log.debug( + if (this.opts.transportContext?.cache) { + this.opts.transportContext?.log.debug( { key: UNIFIEDGRAPH_CACHE_KEY }, 'Searching for Supergraph in cache...', ); this.initialUnifiedGraph$ = handleMaybePromise( - () => this.opts.transportContext.cache?.get(UNIFIEDGRAPH_CACHE_KEY), + () => + this.opts.transportContext?.cache?.get(UNIFIEDGRAPH_CACHE_KEY), (cachedUnifiedGraph) => { if (cachedUnifiedGraph) { - this.opts.transportContext.log.debug( + this.opts.transportContext?.log.debug( { key: UNIFIEDGRAPH_CACHE_KEY }, 'Found Supergraph in cache', ); @@ -217,7 +218,7 @@ export class UnifiedGraphManager implements AsyncDisposable { () => this.initialUnifiedGraph$!, (v) => { this.initialUnifiedGraph$ = undefined; - this.opts.transportContext.log.debug( + this.opts.transportContext?.log.debug( { key: UNIFIEDGRAPH_CACHE_KEY }, 'Initial Supergraph fetched', ); @@ -241,7 +242,7 @@ export class UnifiedGraphManager implements AsyncDisposable { this.lastLoadedUnifiedGraph != null && compareSchemas(loadedUnifiedGraph, this.lastLoadedUnifiedGraph) ) { - this.opts.transportContext.log.debug( + this.opts.transportContext?.log.debug( 'Supergraph has not been changed, skipping...', ); this.lastLoadTime = Date.now(); @@ -268,18 +269,18 @@ export class UnifiedGraphManager implements AsyncDisposable { // 60 seconds making sure the unifiedgraph is not kept forever // NOTE: we default to 60s because Cloudflare KV TTL does not accept anything less 60; - this.opts.transportContext.log.debug( + this.opts.transportContext?.log.debug( { ttl, key: UNIFIEDGRAPH_CACHE_KEY }, 'Caching Supergraph', ); const logCacheSetError = (err: unknown) => { - this.opts.transportContext.log.debug( + this.opts.transportContext?.log.debug( { err, ttl, key: UNIFIEDGRAPH_CACHE_KEY }, 'Unable to cache Supergraph', ); }; try { - const cacheSet$ = this.opts.transportContext.cache.set( + const cacheSet$ = this.opts.transportContext?.cache.set( UNIFIEDGRAPH_CACHE_KEY, serializedUnifiedGraph, { ttl }, @@ -292,7 +293,7 @@ export class UnifiedGraphManager implements AsyncDisposable { logCacheSetError(e); } } catch (err: any) { - this.opts.transportContext.log.error( + this.opts.transportContext?.log.error( err, 'Failed to initiate caching of Supergraph', ); @@ -320,7 +321,7 @@ export class UnifiedGraphManager implements AsyncDisposable { onDelegationPlanHooks: this.onDelegationPlanHooks, onDelegationStageExecuteHooks: this.onDelegationStageExecuteHooks, onDelegateHooks: this.opts.onDelegateHooks, - log: this.opts.transportContext.log, + log: this.opts.transportContext?.log, }); const transportExecutorStack = new AsyncDisposableStack(); const onSubgraphExecute = getOnSubgraphExecute({ @@ -362,7 +363,7 @@ export class UnifiedGraphManager implements AsyncDisposable { }, }, ); - this.opts.transportContext.log.debug( + this.opts.transportContext?.log.debug( 'Supergraph has been changed, updating...', ); } @@ -374,7 +375,7 @@ export class UnifiedGraphManager implements AsyncDisposable { }, (err) => { this.disposeReason = undefined; - this.opts.transportContext.log.error( + this.opts.transportContext?.log.error( err, 'Failed to dispose the existing transports and executors', ); @@ -394,11 +395,11 @@ export class UnifiedGraphManager implements AsyncDisposable { private getAndSetUnifiedGraph(): MaybePromise { return handleMaybePromise( - () => this.opts.getUnifiedGraph(this.opts.transportContext || {}), + () => this.opts.getUnifiedGraph(this.opts.transportContext), (loadedUnifiedGraph: string | GraphQLSchema | DocumentNode) => this.handleLoadedUnifiedGraph(loadedUnifiedGraph), (err) => { - this.opts.transportContext.log.error(err, 'Failed to load Supergraph'); + this.opts.transportContext?.log.error(err, 'Failed to load Supergraph'); this.lastLoadTime = Date.now(); this.disposeReason = undefined; this.polling$ = undefined; diff --git a/packages/fusion-runtime/src/utils.ts b/packages/fusion-runtime/src/utils.ts index 09b597896..73fc867ce 100644 --- a/packages/fusion-runtime/src/utils.ts +++ b/packages/fusion-runtime/src/utils.ts @@ -1,5 +1,5 @@ import { getInstrumented } from '@envelop/instrumentation'; -import type { Logger } from '@graphql-hive/logger'; +import { LegacyLogger, Logger } from '@graphql-hive/logger'; import { loggerForRequest } from '@graphql-hive/logger/request'; import { defaultPrintFn, @@ -108,7 +108,7 @@ function getTransportExecutor({ transports = defaultTransportsGetter, getDisposeReason, }: { - transportContext: TransportContext; + transportContext: TransportContext | undefined; transportEntry: TransportEntry; subgraphName?: string; subgraph: GraphQLSchema; @@ -116,7 +116,7 @@ function getTransportExecutor({ getDisposeReason?: () => GraphQLError | undefined; }): MaybePromise { const kind = transportEntry?.kind || ''; - transportContext.log.debug(`Loading transport "${kind}"`); + transportContext?.log.debug(`Loading transport "${kind}"`); return handleMaybePromise( () => typeof transports === 'function' ? transports(kind) : transports[kind], @@ -143,6 +143,11 @@ function getTransportExecutor({ `Transport "${kind}" "getSubgraphExecutor" is not a function`, ); } + const log = + transportContext?.log || + // if the logger is not provided by the context, create a new silent one just for consistency in the hooks + new Logger({ level: false }); + const logger = transportContext?.logger || LegacyLogger.from(log); return getSubgraphExecutor({ subgraphName, subgraph, @@ -159,6 +164,8 @@ function getTransportExecutor({ }, getDisposeReason, ...transportContext, + log, + logger, }); }, ); @@ -186,7 +193,7 @@ export function getOnSubgraphExecute({ }: { onSubgraphExecuteHooks: OnSubgraphExecuteHook[]; transports?: Transports; - transportContext: TransportContext; + transportContext?: TransportContext; transportEntryMap: Record; getSubgraphSchema(subgraphName: string): GraphQLSchema; transportExecutorStack: AsyncDisposableStack; @@ -203,18 +210,20 @@ export function getOnSubgraphExecute({ let executor: Executor | undefined = subgraphExecutorMap.get(subgraphName); // If the executor is not initialized yet, initialize it if (executor == null) { - let log = executionRequest.context?.request - ? loggerForRequest( - transportContext.log, - executionRequest.context.request, - ) - : transportContext.log; - if (subgraphName) { - log = log.child({ subgraph: subgraphName }); + if (transportContext) { + let log = executionRequest.context?.request + ? loggerForRequest( + transportContext.log, + executionRequest.context.request, + ) + : transportContext.log; + if (subgraphName) { + log = log.child({ subgraph: subgraphName }); + } + // overwrite the log in the transport context because now it contains more details + transportContext.log = log; + log.debug('Initializing executor'); } - // overwrite the log in the transport context because now it contains more details - transportContext.log = log; - log.debug('Initializing executor'); // Lazy executor that loads transport executor on demand executor = function lazyExecutor(subgraphExecReq: ExecutionRequest) { return handleMaybePromise( @@ -273,7 +282,7 @@ export interface WrapExecuteWithHooksOptions { subgraphName: string; transportEntryMap?: Record; getSubgraphSchema: (subgraphName: string) => GraphQLSchema; - transportContext: TransportContext; + transportContext?: TransportContext; instrumentation: () => Instrumentation | undefined; } @@ -305,7 +314,9 @@ export function wrapExecutorWithHooks({ baseExecutionRequest.rootValue = { executionRequest: baseExecutionRequest, }; - const log = transportContext.log.child({ subgraph: subgraphName }); + const log = + transportContext?.log.child({ subgraph: subgraphName }) || + new Logger({ attrs: { subgraph: subgraphName } }); if (onSubgraphExecuteHooks.length === 0) { return baseExecutor(baseExecutionRequest); } @@ -331,7 +342,7 @@ export function wrapExecutorWithHooks({ }, executor, setExecutor(newExecutor) { - log?.debug('executor has been updated'); + log.debug('executor has been updated'); executor = newExecutor; }, log: log, @@ -354,7 +365,7 @@ export function wrapExecutorWithHooks({ onSubgraphExecuteDoneHook({ result: currentResult, setResult(newResult: ExecutionResult) { - log?.debug('overriding result with: ', newResult); + log.debug('overriding result with: ', newResult); currentResult = newResult; }, }), @@ -394,7 +405,7 @@ export function wrapExecutorWithHooks({ onNext({ result: currentResult, setResult: (res) => { - log?.debug('overriding result with: ', res); + log.debug('overriding result with: ', res); currentResult = res; }, diff --git a/packages/fusion-runtime/tests/polling.test.ts b/packages/fusion-runtime/tests/polling.test.ts index ebba31f1c..cb7f7ab43 100644 --- a/packages/fusion-runtime/tests/polling.test.ts +++ b/packages/fusion-runtime/tests/polling.test.ts @@ -61,12 +61,10 @@ describe('Polling', () => { const disposeFn = vi.fn(); - const log = new Logger({ level: false }); await using manager = new UnifiedGraphManager({ getUnifiedGraph: unifiedGraphFetcher, pollingInterval: pollingInterval, batch: false, - transportContext: { log, logger: LegacyLogger.from(log) }, transports() { return { getSubgraphExecutor() { @@ -201,12 +199,10 @@ describe('Polling', () => { }, ]); }); - const log = new Logger({ level: false }); await using manager = new UnifiedGraphManager({ getUnifiedGraph: unifiedGraphFetcher, pollingInterval: pollingInterval, batch: false, - transportContext: { log, logger: LegacyLogger.from(log) }, transports() { return { getSubgraphExecutor() { @@ -299,11 +295,9 @@ describe('Polling', () => { ]); }); let disposeFn = vi.fn(); - const log = new Logger({ level: false }); await using executor = getExecutorForUnifiedGraph({ getUnifiedGraph: unifiedGraphFetcher, pollingInterval: 1000, - transportContext: { log, logger: LegacyLogger.from(log) }, transports() { return { getSubgraphExecutor() { diff --git a/packages/fusion-runtime/tests/utils.ts b/packages/fusion-runtime/tests/utils.ts index fac6acb16..36eed7d76 100644 --- a/packages/fusion-runtime/tests/utils.ts +++ b/packages/fusion-runtime/tests/utils.ts @@ -22,13 +22,8 @@ import { } from '../src/unifiedGraphManager'; export function composeAndGetPublicSchema(subgraphs: SubgraphConfig[]) { - const log = new Logger({ level: false }); const manager = new UnifiedGraphManager({ getUnifiedGraph: () => getUnifiedGraphGracefully(subgraphs), - transportContext: { - log, - logger: LegacyLogger.from(log), - }, transports() { return { getSubgraphExecutor({ subgraphName }) { From dd28f834f470914c6ec1c5ebe3be4a359c90f955 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Mon, 26 May 2025 20:08:28 +0200 Subject: [PATCH 151/157] log mode and version --- packages/gateway/src/commands/proxy.ts | 7 ++++++- packages/gateway/src/commands/subgraph.ts | 7 ++++++- packages/gateway/src/commands/supergraph.ts | 4 +++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/gateway/src/commands/proxy.ts b/packages/gateway/src/commands/proxy.ts index 362c42aa1..f3b9d91b6 100644 --- a/packages/gateway/src/commands/proxy.ts +++ b/packages/gateway/src/commands/proxy.ts @@ -198,11 +198,16 @@ export const addCommand: AddCommand = (ctx, cli) => export type ProxyConfig = GatewayConfigProxy & GatewayCLIConfig; -export async function runProxy({ log }: CLIContext, config: ProxyConfig) { +export async function runProxy( + { log, productName, version }: CLIContext, + config: ProxyConfig, +) { if (handleFork(log, config)) { return; } + log.info(`Starting ${productName} ${version} in proxy mode`); + log.info(`Proxying requests to ${config.proxy.endpoint}`); const runtime = createGatewayRuntime(config); diff --git a/packages/gateway/src/commands/subgraph.ts b/packages/gateway/src/commands/subgraph.ts index 874148a61..0b6b09bc7 100644 --- a/packages/gateway/src/commands/subgraph.ts +++ b/packages/gateway/src/commands/subgraph.ts @@ -158,7 +158,10 @@ export const addCommand: AddCommand = (ctx, cli) => export type SubgraphConfig = GatewayConfigSubgraph & GatewayCLIConfig; -export async function runSubgraph({ log }: CLIContext, config: SubgraphConfig) { +export async function runSubgraph( + { log, productName, version }: CLIContext, + config: SubgraphConfig, +) { let absSchemaPath: string | null = null; if ( typeof config.subgraph === 'string' && @@ -180,6 +183,8 @@ export async function runSubgraph({ log }: CLIContext, config: SubgraphConfig) { return; } + log.info(`Starting ${productName} ${version} as subgraph`); + const runtime = createGatewayRuntime(config); if (absSchemaPath) { diff --git a/packages/gateway/src/commands/supergraph.ts b/packages/gateway/src/commands/supergraph.ts index 3c63c8df8..251423305 100644 --- a/packages/gateway/src/commands/supergraph.ts +++ b/packages/gateway/src/commands/supergraph.ts @@ -259,7 +259,7 @@ export const addCommand: AddCommand = (ctx, cli) => export type SupergraphConfig = GatewayConfigSupergraph & GatewayCLIConfig; export async function runSupergraph( - { log }: CLIContext, + { productName, version, log }: CLIContext, config: SupergraphConfig, ) { let absSchemaPath: string | null = null; @@ -329,6 +329,8 @@ export async function runSupergraph( return; } + log.info(`Starting ${productName} ${version} with supergraph`); + if (config.additionalTypeDefs) { const loaders = [new GraphQLFileLoader(), new CodeFileLoader()]; const additionalTypeDefsArr = asArray(config.additionalTypeDefs); From 84a3ddcf50bd74cabaee601d0f5c8697ed7ca5f4 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Mon, 26 May 2025 20:10:28 +0200 Subject: [PATCH 152/157] only format if there are interpolation values --- packages/logger/src/logger.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/logger/src/logger.ts b/packages/logger/src/logger.ts index e859e15d3..4bfe33a6e 100644 --- a/packages/logger/src/logger.ts +++ b/packages/logger/src/logger.ts @@ -204,7 +204,10 @@ export class Logger implements AsyncDisposable { attrs = shallowMergeAttributes(parseAttrs(this.#attrs), parseAttrs(attrs)); - msg = msg ? format(msg, rest, { stringify: fastSafeStringify }) : msg; + msg = + msg && rest.length + ? format(msg, rest, { stringify: fastSafeStringify }) + : msg; this.write(level, attrs, msg); if (truthyEnv('LOG_TRACE_LOGS')) { From 2d9ce0eee89f3805e5d2dc2c49db44bc36ea85ba Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Mon, 26 May 2025 20:19:19 +0200 Subject: [PATCH 153/157] earlier version and mode log --- packages/gateway/src/commands/proxy.ts | 10 ++++------ packages/gateway/src/commands/subgraph.ts | 10 ++++------ packages/gateway/src/commands/supergraph.ts | 8 +++++--- 3 files changed, 13 insertions(+), 15 deletions(-) diff --git a/packages/gateway/src/commands/proxy.ts b/packages/gateway/src/commands/proxy.ts index f3b9d91b6..781308617 100644 --- a/packages/gateway/src/commands/proxy.ts +++ b/packages/gateway/src/commands/proxy.ts @@ -44,6 +44,9 @@ export const addCommand: AddCommand = (ctx, cli) => hivePersistedDocumentsToken, ...opts } = this.optsWithGlobals(); + + ctx.log.info(`Starting ${ctx.productName} ${ctx.version} in proxy mode`); + const loadedConfig = await loadConfig({ log: ctx.log, configPath: opts.configPath, @@ -198,16 +201,11 @@ export const addCommand: AddCommand = (ctx, cli) => export type ProxyConfig = GatewayConfigProxy & GatewayCLIConfig; -export async function runProxy( - { log, productName, version }: CLIContext, - config: ProxyConfig, -) { +export async function runProxy({ log }: CLIContext, config: ProxyConfig) { if (handleFork(log, config)) { return; } - log.info(`Starting ${productName} ${version} in proxy mode`); - log.info(`Proxying requests to ${config.proxy.endpoint}`); const runtime = createGatewayRuntime(config); diff --git a/packages/gateway/src/commands/subgraph.ts b/packages/gateway/src/commands/subgraph.ts index 0b6b09bc7..b76b82e50 100644 --- a/packages/gateway/src/commands/subgraph.ts +++ b/packages/gateway/src/commands/subgraph.ts @@ -45,6 +45,9 @@ export const addCommand: AddCommand = (ctx, cli) => hivePersistedDocumentsToken, ...opts } = this.optsWithGlobals(); + + ctx.log.info(`Starting ${ctx.productName} ${ctx.version} as subgraph`); + const loadedConfig = await loadConfig({ log: ctx.log, configPath: opts.configPath, @@ -158,10 +161,7 @@ export const addCommand: AddCommand = (ctx, cli) => export type SubgraphConfig = GatewayConfigSubgraph & GatewayCLIConfig; -export async function runSubgraph( - { log, productName, version }: CLIContext, - config: SubgraphConfig, -) { +export async function runSubgraph({ log }: CLIContext, config: SubgraphConfig) { let absSchemaPath: string | null = null; if ( typeof config.subgraph === 'string' && @@ -183,8 +183,6 @@ export async function runSubgraph( return; } - log.info(`Starting ${productName} ${version} as subgraph`); - const runtime = createGatewayRuntime(config); if (absSchemaPath) { diff --git a/packages/gateway/src/commands/supergraph.ts b/packages/gateway/src/commands/supergraph.ts index 251423305..ef7cbed81 100644 --- a/packages/gateway/src/commands/supergraph.ts +++ b/packages/gateway/src/commands/supergraph.ts @@ -65,6 +65,10 @@ export const addCommand: AddCommand = (ctx, cli) => // TODO: move to optsWithGlobals once https://github.com/commander-js/extra-typings/pull/76 is merged const { apolloUplink } = this.opts(); + ctx.log.info( + `Starting ${ctx.productName} ${ctx.version} with supergraph`, + ); + const loadedConfig = await loadConfig({ log: ctx.log, configPath: opts.configPath, @@ -259,7 +263,7 @@ export const addCommand: AddCommand = (ctx, cli) => export type SupergraphConfig = GatewayConfigSupergraph & GatewayCLIConfig; export async function runSupergraph( - { productName, version, log }: CLIContext, + { log }: CLIContext, config: SupergraphConfig, ) { let absSchemaPath: string | null = null; @@ -329,8 +333,6 @@ export async function runSupergraph( return; } - log.info(`Starting ${productName} ${version} with supergraph`); - if (config.additionalTypeDefs) { const loaders = [new GraphQLFileLoader(), new CodeFileLoader()]; const additionalTypeDefsArr = asArray(config.additionalTypeDefs); From 7184bde516d9890379b9bed7d06426fad8c16546 Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Mon, 26 May 2025 20:22:50 +0200 Subject: [PATCH 154/157] use template literals --- packages/gateway/src/commands/handleFork.ts | 4 +- packages/gateway/src/commands/supergraph.ts | 57 +++++++++++-------- packages/gateway/src/servers/bun.ts | 2 +- packages/gateway/src/servers/nodeHttp.ts | 4 +- .../hmac-upstream-signature/src/index.ts | 12 ++-- packages/plugins/jwt-auth/src/index.ts | 3 +- packages/runtime/src/fetchers/graphos.ts | 3 +- packages/runtime/src/plugins/useWebhooks.ts | 7 +-- .../transports/http-callback/src/index.ts | 10 ++-- 9 files changed, 52 insertions(+), 50 deletions(-) diff --git a/packages/gateway/src/commands/handleFork.ts b/packages/gateway/src/commands/handleFork.ts index 7b72603dc..a5c56091b 100644 --- a/packages/gateway/src/commands/handleFork.ts +++ b/packages/gateway/src/commands/handleFork.ts @@ -10,7 +10,7 @@ export function handleFork(log: Logger, config: { fork?: number }): boolean { if (cluster.isPrimary && config.fork && config.fork > 1) { const workers = new Set(); let expectedToExit = false; - log.debug('Forking %d workers', config.fork); + log.debug(`Forking ${config.fork} workers`); for (let i = 0; i < config.fork; i++) { const worker = cluster.fork(); const workerLogger = log.child({ worker: worker.id }); @@ -38,7 +38,7 @@ export function handleFork(log: Logger, config: { fork?: number }): boolean { workers.add(worker); } registerTerminateHandler((signal) => { - log.info('Killing workers on %s', signal); + log.info(`Killing workers on ${signal}`); expectedToExit = true; workers.forEach((w) => { w.kill(signal); diff --git a/packages/gateway/src/commands/supergraph.ts b/packages/gateway/src/commands/supergraph.ts index ef7cbed81..be111668b 100644 --- a/packages/gateway/src/commands/supergraph.ts +++ b/packages/gateway/src/commands/supergraph.ts @@ -79,15 +79,14 @@ export const addCommand: AddCommand = (ctx, cli) => let supergraph: | UnifiedGraphConfig | GatewayHiveCDNOptions - | GatewayGraphOSManagedFederationOptions = 'supergraph.graphql'; + | GatewayGraphOSManagedFederationOptions = './supergraph.graphql'; if (schemaPathOrUrl) { - ctx.log.info('Supergraph will be loaded from %s', schemaPathOrUrl); + ctx.log.info(`Supergraph will be loaded from ${schemaPathOrUrl}`); if (hiveCdnKey) { ctx.log.info('Using Hive CDN key'); if (!isUrl(schemaPathOrUrl)) { ctx.log.error( - 'Hive CDN endpoint must be a URL when providing --hive-cdn-key but got %s', - schemaPathOrUrl, + `Hive CDN endpoint must be a URL when providing --hive-cdn-key but got ${schemaPathOrUrl}`, ); process.exit(1); } @@ -100,8 +99,7 @@ export const addCommand: AddCommand = (ctx, cli) => ctx.log.info('Using GraphOS API key'); if (!schemaPathOrUrl.includes('@')) { ctx.log.error( - `Apollo GraphOS requires a graph ref in the format @ when providing --apollo-key. Please provide a valid graph ref not %s.`, - schemaPathOrUrl, + `Apollo GraphOS requires a graph ref in the format @ when providing --apollo-key. Please provide a valid graph ref not ${schemaPathOrUrl}.`, ); process.exit(1); } @@ -128,7 +126,7 @@ export const addCommand: AddCommand = (ctx, cli) => ); process.exit(1); } - ctx.log.info('Using Hive CDN endpoint %s', hiveCdnEndpoint); + ctx.log.info(`Using Hive CDN endpoint ${hiveCdnEndpoint}`); supergraph = { type: 'hive', endpoint: hiveCdnEndpoint, @@ -137,8 +135,7 @@ export const addCommand: AddCommand = (ctx, cli) => } else if (apolloGraphRef) { if (!apolloGraphRef.includes('@')) { ctx.log.error( - 'Apollo GraphOS requires a graph ref in the format @. Please provide a valid graph ref not %s.', - apolloGraphRef, + `Apollo GraphOS requires a graph ref in the format @. Please provide a valid graph ref not ${apolloGraphRef}.`, ); process.exit(1); } @@ -148,7 +145,7 @@ export const addCommand: AddCommand = (ctx, cli) => ); process.exit(1); } - ctx.log.info('Using Apollo Graph Ref %s', apolloGraphRef); + ctx.log.info(`Using Apollo Graph Ref ${apolloGraphRef}`); supergraph = { type: 'graphos', apiKey: apolloKey, @@ -159,7 +156,7 @@ export const addCommand: AddCommand = (ctx, cli) => supergraph = loadedConfig.supergraph!; // TODO: assertion wont be necessary when exactOptionalPropertyTypes // TODO: how to provide hive-cdn-key? } else { - ctx.log.info('Using default supergraph location %s', supergraph); + ctx.log.info(`Using default supergraph location "${supergraph}"`); } const registryConfig: Pick = {}; @@ -276,13 +273,13 @@ export async function runSupergraph( absSchemaPath = isAbsolute(supergraphPath) ? String(supergraphPath) : resolve(process.cwd(), supergraphPath); - log.info('Reading supergraph from %s', absSchemaPath); + log.info({ path: absSchemaPath }, 'Reading supergraph'); try { await lstat(absSchemaPath); - } catch { + } catch (err) { log.error( - 'Could not read supergraph from %s. Make sure the file exists.', - absSchemaPath, + { path: absSchemaPath, err }, + 'Could not read supergraph. Make sure the file exists.', ); process.exit(1); } @@ -292,11 +289,14 @@ export async function runSupergraph( // Polling should not be enabled when watching the file delete config.pollingInterval; if (cluster.isPrimary) { - log.info('Watching %s for changes', absSchemaPath); + log.info({ path: absSchemaPath }, 'Watching supergraph for changes'); const ctrl = new AbortController(); registerTerminateHandler((signal) => { - log.info('Closing watcher for %s on %s', absSchemaPath, signal); + log.info( + { path: absSchemaPath }, + `Closing watcher for supergraph on ${signal}`, + ); return ctrl.abort(`Process terminated on ${signal}`); }); @@ -308,7 +308,10 @@ export async function runSupergraph( // TODO: or should we just ignore? throw new Error(`Supergraph file was renamed to "${f.filename}"`); } - log.info('%s changed. Invalidating supergraph...', absSchemaPath); + log.info( + { path: absSchemaPath }, + 'Supergraph changed. Invalidating...', + ); if (config.fork && config.fork > 1) { for (const workerId in cluster.workers) { cluster.workers[workerId]!.send('invalidateUnifiedGraph'); @@ -321,10 +324,16 @@ export async function runSupergraph( })() .catch((e) => { if (e.name === 'AbortError') return; - log.error(e, 'Watcher for %s closed with an error', absSchemaPath); + log.error( + { path: absSchemaPath, err: e }, + 'Supergraph watcher closed with an error', + ); }) .then(() => { - log.info('Watcher for %s successfuly closed', absSchemaPath); + log.info( + { path: absSchemaPath }, + 'Supergraph watcher successfuly closed', + ); }); } } @@ -359,17 +368,17 @@ export async function runSupergraph( const runtime = createGatewayRuntime(config); if (absSchemaPath) { - log.info('Serving local supergraph from %s', absSchemaPath); + log.info({ path: absSchemaPath }, 'Serving local supergraph'); } else if (isUrl(String(config.supergraph))) { - log.info('Serving remote supergraph from %s', config.supergraph); + log.info({ url: config.supergraph }, 'Serving remote supergraph'); } else if ( typeof config.supergraph === 'object' && 'type' in config.supergraph && config.supergraph.type === 'hive' ) { log.info( - 'Serving supergraph from Hive CDN at %s', - config.supergraph.endpoint, + { endpoint: config.supergraph.endpoint }, + 'Serving supergraph from Hive CDN', ); } else { log.info('Serving supergraph from config'); diff --git a/packages/gateway/src/servers/bun.ts b/packages/gateway/src/servers/bun.ts index 85a4acf1e..628838a67 100644 --- a/packages/gateway/src/servers/bun.ts +++ b/packages/gateway/src/servers/bun.ts @@ -64,6 +64,6 @@ export async function startBunServer>( }; } const server = Bun.serve(serverOptions); - opts.log.info('Listening on %s', server.url); + opts.log.info(`Listening on ${server.url}`); gwRuntime.disposableStack.use(server); } diff --git a/packages/gateway/src/servers/nodeHttp.ts b/packages/gateway/src/servers/nodeHttp.ts index e54d96c3e..978c916cb 100644 --- a/packages/gateway/src/servers/nodeHttp.ts +++ b/packages/gateway/src/servers/nodeHttp.ts @@ -77,7 +77,7 @@ export async function startNodeHttpServer>( const url = `${protocol}://${host}:${port}`.replace('0.0.0.0', 'localhost'); - log.debug('Starting server on %s', url); + log.debug(`Starting server on ${url}`); if (!disableWebsockets) { log.debug('Setting up WebSocket server'); const { WebSocketServer } = await import('ws'); @@ -112,7 +112,7 @@ export async function startNodeHttpServer>( return new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, host, () => { - log.info('Listening on %s', url); + log.info(`Listening on ${url}`); gwRuntime.disposableStack.defer( () => new Promise((resolve) => { diff --git a/packages/plugins/hmac-upstream-signature/src/index.ts b/packages/plugins/hmac-upstream-signature/src/index.ts index 71ad25246..4bec1c30d 100644 --- a/packages/plugins/hmac-upstream-signature/src/index.ts +++ b/packages/plugins/hmac-upstream-signature/src/index.ts @@ -100,12 +100,11 @@ export function useHmacUpstreamSignature( log: rootLog, }) { const log = rootLog.child('[useHmacUpstreamSignature] '); - log.debug('Running shouldSign for subgraph %s', subgraphName); + log.debug(`Running shouldSign for subgraph ${subgraphName}`); if (shouldSign({ subgraphName, subgraph, executionRequest })) { log.debug( - 'shouldSign is true for subgraph %s, signing request', - subgraphName, + `shouldSign is true for subgraph ${subgraphName}, signing request`, ); textEncoder ||= new fetchAPI.TextEncoder(); return handleMaybePromise( @@ -134,8 +133,7 @@ export function useHmacUpstreamSignature( signature: extensionValue, payload: serializedExecutionRequest, }, - 'Produced hmac signature for subgraph %s', - subgraphName, + `Produced hmac signature for subgraph ${subgraphName}`, ); if (!executionRequest.extensions) { @@ -205,8 +203,8 @@ export function useHmacSignatureValidation( ); const serializedParams = paramsSerializer(params); log.debug( - 'HMAC signature will be calculate based on serialized params %s', - serializedParams, + { serializedParams }, + 'HMAC signature will be calculate based on serialized params', ); return handleMaybePromise( diff --git a/packages/plugins/jwt-auth/src/index.ts b/packages/plugins/jwt-auth/src/index.ts index 89c9b57c6..351ebbb82 100644 --- a/packages/plugins/jwt-auth/src/index.ts +++ b/packages/plugins/jwt-auth/src/index.ts @@ -88,8 +88,7 @@ export function useJWT( log.debug( { payload: jwtData.payload }, - '[useJWT] Forwarding JWT payload to subgraph %s', - subgraphName, + `[useJWT] Forwarding JWT payload to subgraph ${subgraphName}`, ); setExecutionRequest({ diff --git a/packages/runtime/src/fetchers/graphos.ts b/packages/runtime/src/fetchers/graphos.ts index f9b58122e..8894bb939 100644 --- a/packages/runtime/src/fetchers/graphos.ts +++ b/packages/runtime/src/fetchers/graphos.ts @@ -81,8 +81,7 @@ export function createGraphOSFetcher({ if (nextFetchTime >= currentTime) { const delay = nextFetchTime - currentTime; log.info( - 'Fetching supergraph with delay %s', - millisecondsToStr(delay), + `Fetching supergraph with delay ${millisecondsToStr(delay)}`, ); nextFetchTime = 0; return delayInMs(delay).then(fetchSupergraph); diff --git a/packages/runtime/src/plugins/useWebhooks.ts b/packages/runtime/src/plugins/useWebhooks.ts index deab67092..1fddbf0bd 100644 --- a/packages/runtime/src/plugins/useWebhooks.ts +++ b/packages/runtime/src/plugins/useWebhooks.ts @@ -34,14 +34,13 @@ export function useWebhooks({ const expectedEventName = `webhook:${requestMethod}:${pathname}`; for (const eventName of eventNames) { if (eventName === expectedEventName) { - log.debug('Received webhook request for %s', pathname); + log.debug({ pathname }, 'Received webhook request'); return handleMaybePromise( () => request.text(), function handleWebhookPayload(webhookPayload) { log.debug( - { payload: webhookPayload }, - 'Emitted webhook request for %s', - pathname, + { pathname, payload: webhookPayload }, + 'Emitted webhook request', ); webhookPayload = request.headers.get('content-type') === 'application/json' diff --git a/packages/transports/http-callback/src/index.ts b/packages/transports/http-callback/src/index.ts index a4dda7f95..8c006dbbf 100644 --- a/packages/transports/http-callback/src/index.ts +++ b/packages/transports/http-callback/src/index.ts @@ -139,9 +139,8 @@ export default { }, heartbeatIntervalMs), ); log.debug( - 'Subscribing to %s with callbackUrl: %s', - transportEntry.location, - callbackUrl, + { location: transportEntry.location, callbackUrl }, + 'Subscribing using callback', ); let pushFn: Push = () => { throw new Error( @@ -248,14 +247,13 @@ export default { pushFn = push; stopSubscription = stop; stopFnSet.add(stop); - log.debug('Listening to %s', subscriptionCallbackPath); + log.debug(`Listening to ${subscriptionCallbackPath}`); const subId = pubsub.subscribe( `webhook:post:${subscriptionCallbackPath}`, (message: HTTPCallbackMessage) => { log.debug( message, - 'Received message from %s', - subscriptionCallbackPath, + `Received message from ${subscriptionCallbackPath}`, ); if (message.verifier !== verifier) { return; From 6b7df87639970bc4f5b2244af620413010dcd55c Mon Sep 17 00:00:00 2001 From: theguild-bot Date: Thu, 5 Jun 2025 12:55:48 +0000 Subject: [PATCH 155/157] docs(examples): converted from e2es --- examples/hmac-auth-https/example.tar.gz | Bin 78803 -> 78848 bytes examples/hmac-auth-https/package-lock.json | 10 ++++++++++ examples/hmac-auth-https/package.json | 1 + 3 files changed, 11 insertions(+) diff --git a/examples/hmac-auth-https/example.tar.gz b/examples/hmac-auth-https/example.tar.gz index 3a6dfb2c3efe163716c5fdec6a299a8fa112e691..4df0cd066d5525b2f79217100fb46e1370e25b77 100644 GIT binary patch delta 3954 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 M5AZf>!vM$y02e^Exc~qF delta 3909 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 T4?OVT{}uiRTeQAb0LTRZT4t@t diff --git a/examples/hmac-auth-https/package-lock.json b/examples/hmac-auth-https/package-lock.json index 69424220c..c373a1103 100644 --- a/examples/hmac-auth-https/package-lock.json +++ b/examples/hmac-auth-https/package-lock.json @@ -11,6 +11,7 @@ "@apollo/server": "^4.12.2", "@apollo/subgraph": "^2.11.0", "@graphql-hive/gateway": "^1.15.0", + "@graphql-hive/logger": "^0.0.0", "@graphql-mesh/compose-cli": "^1.4.1", "@graphql-mesh/hmac-upstream-signature": "^1.2.27", "@graphql-mesh/plugin-jwt-auth": "^1.5.5", @@ -2103,6 +2104,15 @@ "node": ">=18.0.0" } }, + "node_modules/@graphql-hive/logger": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/@graphql-hive/logger/-/logger-0.0.0.tgz", + "integrity": "sha512-OYT33fYvSvPcbqfjZ4uniKQe+525oAuk2k3MKVwIBMjA7oFIjgCoUH0AlKyRjbXuFlmKDeidn+3bzY3S5nNqFw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@graphql-hive/logger-json": { "version": "0.0.4", "resolved": "https://registry.npmjs.org/@graphql-hive/logger-json/-/logger-json-0.0.4.tgz", diff --git a/examples/hmac-auth-https/package.json b/examples/hmac-auth-https/package.json index 379991fe4..aed850bb3 100644 --- a/examples/hmac-auth-https/package.json +++ b/examples/hmac-auth-https/package.json @@ -13,6 +13,7 @@ "@apollo/server": "^4.12.2", "@apollo/subgraph": "^2.11.0", "@graphql-hive/gateway": "^1.15.0", + "@graphql-hive/logger": "^0.0.0", "@graphql-mesh/compose-cli": "^1.4.1", "@graphql-mesh/hmac-upstream-signature": "^1.2.27", "@graphql-mesh/plugin-jwt-auth": "^1.5.5", From 274edcc79941074731c9cecfc2171fde0d73975b Mon Sep 17 00:00:00 2001 From: Denis Badurina Date: Thu, 5 Jun 2025 15:30:25 +0200 Subject: [PATCH 156/157] update expected log for schema watcher --- e2e/supergraph-file-watcher/supergraph-file-watcher.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/supergraph-file-watcher/supergraph-file-watcher.e2e.ts b/e2e/supergraph-file-watcher/supergraph-file-watcher.e2e.ts index 5e1fba2f1..38634cfd1 100644 --- a/e2e/supergraph-file-watcher/supergraph-file-watcher.e2e.ts +++ b/e2e/supergraph-file-watcher/supergraph-file-watcher.e2e.ts @@ -45,7 +45,7 @@ it('should detect supergraph file change and reload schema', async () => { for (;;) { timeout.throwIfAborted(); await setTimeout(100); - if (gw.getStd('both').match(/invalidating supergraph/i)) { + if (gw.getStd('both').match(/supergraph changed/i)) { break; } } From 6513e64bd4b1b2cfcb14ed18ee4d22dafa49e976 Mon Sep 17 00:00:00 2001 From: theguild-bot Date: Thu, 5 Jun 2025 13:35:17 +0000 Subject: [PATCH 157/157] docs(examples): converted from e2es --- examples/hmac-auth-https/example.tar.gz | Bin 78848 -> 78947 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/examples/hmac-auth-https/example.tar.gz b/examples/hmac-auth-https/example.tar.gz index 4df0cd066d5525b2f79217100fb46e1370e25b77..820f5b5df692e50b9c8d46dd5c0cb0870b46733e 100644 GIT binary patch delta 77330 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

ok2N-B;oMvxyW31rzg4|mDFb8NmSWUgvX8L$? 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¥hpg$;|+Pn$-SHBL@&y5 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