Repository: NangoHQ/nango
Pull request #3765: fix(logs): buffer context, log oauth2 http calls
Author: login: bodinsamuel
Created: 2025-03-24T22:43:53Z | Merged: 2025-03-25T15:43:21Z

## Changes

Contributes to https://linear.app/nango/issue/NAN-2975/investigate-failed-refresh

- Add log context buffer class
I got annoyed at buffering logs in an array and passing up the array. We now have a buffer class that acts like a regular logCtx so they are used without knowing if it's a buffer or not.

- Pass log buffer to oauth2 client
I was trying to debug something for a customer but we were not logging oauth2 calls. simple-oauth2 is hiding a lot of stuff, it's really annoying


Diff:
diff --git a/packages/logs/lib/client.ts b/packages/logs/lib/client.ts
index 0643600b81..ffbbf8bef6 100644
--- a/packages/logs/lib/client.ts
+++ b/packages/logs/lib/client.ts
@@ -1,17 +1,19 @@
-import { metrics, report } from '@nangohq/utils';
+import { report } from '@nangohq/utils';
 
 import { envs } from './env.js';
 import { errorToDocument } from './formatters.js';
-import { getFormattedMessage } from './models/helpers.js';
-import { createMessage, setCancelled, setFailed, setRunning, setSuccess, setTimeouted, updateOperation } from './models/messages.js';
-import { isCli, logLevelToLogger, logger } from './utils.js';
+import { setCancelled, setFailed, setRunning, setSuccess, setTimeouted, updateOperation } from './models/messages.js';
+import { ESTransport } from './transport.js';
+import { isCli, logger } from './utils.js';
 
 import type { OtlpSpan } from './otlp/otlpSpan.js';
+import type { LogTransportAbstract } from './transport.js';
 import type { MessageHTTPRequest, MessageHTTPResponse, MessageMeta, MessageRow, MessageRowInsert, OperationRow } from '@nangohq/types';
 
 interface Options {
     dryRun?: boolean;
     logToConsole?: boolean;
+    transport?: LogTransportAbstract;
 }
 
 /**
@@ -23,64 +25,55 @@ export class LogContextStateless {
     accountId?: OperationRow['accountId'] | undefined;
     dryRun: boolean;
     logToConsole: boolean;
+    transport: LogTransportAbstract;
 
     constructor(data: { id: OperationRow['id']; accountId?: OperationRow['accountId'] | undefined }, options: Options = { dryRun: false, logToConsole: true }) {
         this.id = data.id;
         this.accountId = data.accountId;
         this.dryRun = isCli || !envs.NANGO_LOGS_ENABLED ? true : options.dryRun || false;
         this.logToConsole = options.logToConsole ?? true;
+        this.transport = options.transport ?? new ESTransport();
     }
 
-    async log(data: MessageRowInsert): Promise<boolean> {
-        if (data.error && data.error.constructor.name !== 'Object') {
-            data.error = errorToDocument(data.error);
-        }
-
-        if (this.logToConsole) {
-            const obj: Record<string, any> = {};
-            if (data.error) obj['error'] = data.error;
-            if (data.meta) obj['meta'] = data.meta;
-            logger[logLevelToLogger[data.level]](`${this.dryRun ? '[dry] ' : ''}log: ${data.message}`, Object.keys(obj).length > 0 ? obj : undefined);
-        }
-        if (this.dryRun) {
-            return true;
-        }
-
-        const start = Date.now();
-        try {
-            await createMessage(getFormattedMessage({ ...data, parentId: this.id }));
-            return true;
-        } catch (err) {
-            report(new Error('failed_to_insert_in_es', { cause: err }));
-            return false;
-        } finally {
-            metrics.duration(metrics.Types.LOGS_LOG, Date.now() - start, { accountId: this.accountId as number });
-        }
+    async log(msg: MessageRowInsert) {
+        return await this.transport.log(msg, { dryRun: this.dryRun, logToConsole: this.logToConsole, operationId: this.id, accountId: this.accountId });
     }
 
     async debug(message: string, meta?: MessageMeta): Promise<boolean> {
-        return await this.log({ type: 'log', level: 'debug', message, meta, source: 'internal', createdAt: new Date().toISOString() });
+        return await this.transport.log(
+            { type: 'log', level: 'debug', message, meta, source: 'internal', createdAt: new Date().toISOString() },
+            { dryRun: this.dryRun, logToConsole: this.logToConsole, operationId: this.id, accountId: this.accountId }
+        );
     }
 
     async info(message: string, meta?: MessageMeta): Promise<boolean> {
-        return await this.log({ type: 'log', level: 'info', message, meta, source: 'internal', createdAt: new Date().toISOString() });
+        return await this.transport.log(
+            { type: 'log', level: 'info', message, meta, source: 'internal', createdAt: new Date().toISOString() },
+            { dryRun: this.dryRun, logToConsole: this.logToConsole, operationId: this.id, accountId: this.accountId }
+        );
     }
 
     async warn(message: string, meta?: MessageMeta): Promise<boolean> {
-        return await this.log({ type: 'log', level: 'warn', message, meta, source: 'internal', createdAt: new Date().toISOString() });
+        return await this.transport.log(
+            { type: 'log', level: 'warn', message, meta, source: 'internal', createdAt: new Date().toISOString() },
+            { dryRun: this.dryRun, logToConsole: this.logToConsole, operationId: this.id, accountId: this.accountId }
+        );
     }
 
     async error(message: string, meta: (MessageMeta & { error?: unknown; err?: never; e?: never }) | null = null): Promise<boolean> {
         const { error, ...rest } = meta || {};
-        return await this.log({
-            type: 'log',
-            level: 'error',
-            message,
-            error: errorToDocument(error),
-            meta: Object.keys(rest).length > 0 ? rest : undefined,
-            source: 'internal',
-            createdAt: new Date().toISOString()
-        });
+        return await this.transport.log(
+            {
+                type: 'log',
+                level: 'error',
+                message,
+                error: errorToDocument(error),
+                meta: Object.keys(rest).length > 0 ? rest : undefined,
+                source: 'internal',
+                createdAt: new Date().toISOString()
+            },
+            { dryRun: this.dryRun, logToConsole: this.logToConsole, operationId: this.id, accountId: this.accountId }
+        );
     }
 
     async http(
@@ -103,24 +96,34 @@ export class LogContextStateless {
     ): Promise<boolean> {
         const level: MessageRow['level'] = data.level ?? (data.response && data.response.code >= 400 ? 'error' : 'info');
         const endedAt = userDefinedEndedAt || new Date();
-        return await this.log({
-            type: 'http',
-            level,
-            message,
-            ...data,
-            error: errorToDocument(error),
-            source: 'internal',
-            createdAt: createdAt.toISOString(),
-            endedAt: endedAt.toISOString(),
-            durationMs: endedAt.getTime() - createdAt.getTime()
-        });
+        return await this.transport.log(
+            {
+                type: 'http',
+                level,
+                message,
+                ...data,
+                error: errorToDocument(error),
+                source: 'internal',
+                createdAt: createdAt.toISOString(),
+                endedAt: endedAt.toISOString(),
+                durationMs: endedAt.getTime() - createdAt.getTime()
+            },
+            { dryRun: this.dryRun, logToConsole: this.logToConsole, operationId: this.id, accountId: this.accountId }
+        );
     }
 
     /**
      * @deprecated Only there for retro compat
      */
     async trace(message: string, meta?: MessageMeta): Promise<boolean> {
-        return await this.log({ type: 'log', level: 'debug', message, meta, source: 'internal', createdAt: new Date().toISOString() });
+        return await this.transport.log(
+            { type: 'log', level: 'debug', message, meta, source: 'internal', createdAt: new Date().toISOString() },
+            { dryRun: this.dryRun, logToConsole: this.logToConsole, operationId: this.id, accountId: this.accountId }
+        );
+    }
+
+    async merge(logCtx: LogContextStateless) {
+        await this.transport.merge(logCtx, this);
     }
 }
 
diff --git a/packages/logs/lib/models/helpers.ts b/packages/logs/lib/models/helpers.ts
index 1b3278fb5d..69c064ac6b 100644
--- a/packages/logs/lib/models/helpers.ts
+++ b/packages/logs/lib/models/helpers.ts
@@ -1,9 +1,11 @@
-import { nanoid } from '@nangohq/utils';
-import type { ConcatOperationList, MessageRow, MessageRowInsert, OperationRow, OperationRowInsert } from '@nangohq/types';
 import { z } from 'zod';
-import type { estypes } from '@elastic/elasticsearch';
+
+import { nanoid } from '@nangohq/utils';
+
 import { defaultOperationExpiration } from '../env.js';
-import type { LogContext } from '../client.js';
+
+import type { estypes } from '@elastic/elasticsearch';
+import type { ConcatOperationList, MessageRow, OperationRow, OperationRowInsert } from '@nangohq/types';
 import type { SetRequired } from 'type-fest';
 
 export const operationIdRegex = z.string().regex(/^[a-zA-Z0-9_]{20,25}$/);
@@ -123,15 +125,3 @@ export const operationTypeToMessage: Record<ConcatOperationList, string> = {
     'events:post_connection_creation': 'Event-based executions',
     'events:pre_connection_deletion': 'Event-based executions'
 };
-
-/**
- * Send buffered logs to elasticsearch
- * Ultimately it would be better to have LogContextBuffer (or an option in LogContext)
- */
-export async function flushLogsBuffer(logs: MessageRowInsert[], logCtx: LogContext) {
-    await Promise.all(
-        logs.map(async (log) => {
-            await logCtx.log(log);
-        })
-    );
-}
diff --git a/packages/logs/lib/models/logContextGetter.ts b/packages/logs/lib/models/logContextGetter.ts
index 9a4f8cc09d..da90541e30 100644
--- a/packages/logs/lib/models/logContextGetter.ts
+++ b/packages/logs/lib/models/logContextGetter.ts
@@ -5,6 +5,7 @@ import { envs } from '../env.js';
 import { logger } from '../utils.js';
 import { getFormattedOperation } from './helpers.js';
 import { createOperation } from './messages.js';
+import { BufferTransport } from '../transport.js';
 
 import type { AdditionalOperationData } from './helpers.js';
 import type { OperationRow, OperationRowInsert } from '@nangohq/types';
@@ -63,5 +64,9 @@ export const logContextGetter = {
 
     getStateLess({ id, accountId }: { id: OperationRow['id']; accountId: OperationRow['accountId'] }, options?: Options): LogContextStateless {
         return new LogContextStateless({ id, accountId }, options);
+    },
+
+    getBuffer({ id, accountId }: { id?: OperationRow['id']; accountId: OperationRow['accountId'] }, options?: Options): LogContextStateless {
+        return new LogContextStateless({ id: id || '-1', accountId }, { ...options, transport: new BufferTransport() });
     }
 };
diff --git a/packages/logs/lib/transport.ts b/packages/logs/lib/transport.ts
new file mode 100644
index 0000000000..0be6925ea3
--- /dev/null
+++ b/packages/logs/lib/transport.ts
@@ -0,0 +1,74 @@
+import { metrics, report } from '@nangohq/utils';
+
+import { errorToDocument } from './formatters.js';
+import { getFormattedMessage } from './models/helpers.js';
+import { createMessage } from './models/messages.js';
+import { logLevelToLogger, logger } from './utils.js';
+
+import type { LogContextStateless } from './client.js';
+import type { MaybePromise, MessageRowInsert } from '@nangohq/types';
+
+interface Props {
+    operationId: string;
+    accountId?: number | undefined;
+    dryRun: boolean;
+    logToConsole: boolean;
+}
+
+export abstract class LogTransportAbstract {
+    abstract log(msg: MessageRowInsert, { accountId, dryRun, logToConsole }: Props): MaybePromise<boolean>;
+    abstract merge(source: LogContextStateless, destination: LogContextStateless): MaybePromise<void>;
+}
+
+export class ESTransport implements LogTransportAbstract {
+    async log(data: MessageRowInsert, { operationId, accountId, dryRun, logToConsole }: Props): Promise<boolean> {
+        if (data.error && data.error.constructor.name !== 'Object') {
+            data.error = errorToDocument(data.error);
+        }
+
+        if (logToConsole) {
+            const obj: Record<string, any> = {};
+            if (data.error) obj['error'] = data.error;
+            if (data.meta) obj['meta'] = data.meta;
+            logger[logLevelToLogger[data.level]](`${dryRun ? '[dry] ' : ''}log: ${data.message}`, Object.keys(obj).length > 0 ? obj : undefined);
+        }
+        if (dryRun) {
+            return true;
+        }
+
+        const start = Date.now();
+        try {
+            await createMessage(getFormattedMessage({ ...data, parentId: operationId }));
+            return true;
+        } catch (err) {
+            report(new Error('failed_to_insert_in_es', { cause: err }));
+            return false;
+        } finally {
+            metrics.duration(metrics.Types.LOGS_LOG, Date.now() - start, { accountId: accountId as number });
+        }
+    }
+
+    merge(source: LogContextStateless, destination: LogContextStateless): void {
+        if (source.transport instanceof BufferTransport) {
+            void Promise.all(
+                source.transport.buffer.map(async (log) => {
+                    await destination.log(log);
+                })
+            );
+        }
+    }
+}
+
+export class BufferTransport implements LogTransportAbstract {
+    buffer: MessageRowInsert[] = [];
+    log(msg: MessageRowInsert): boolean {
+        this.buffer.push(msg);
+        return true;
+    }
+
+    merge(source: LogContextStateless): void {
+        if (source.transport instanceof BufferTransport) {
+            this.buffer.push(...source.transport.buffer);
+        }
+    }
+}
diff --git a/packages/server/lib/controllers/auth/postApiKey.ts b/packages/server/lib/controllers/auth/postApiKey.ts
index f787db17c9..213c81a7d7 100644
--- a/packages/server/lib/controllers/auth/postApiKey.ts
+++ b/packages/server/lib/controllers/auth/postApiKey.ts
@@ -1,31 +1,34 @@
-import type { NextFunction } from 'express';
 import { z } from 'zod';
-import { asyncWrapper } from '../../utils/asyncWrapper.js';
-import { zodErrorToHTTP, stringifyError, metrics } from '@nangohq/utils';
+
+import db from '@nangohq/database';
+import { defaultOperationExpiration, endUserToMeta, logContextGetter } from '@nangohq/logs';
 import {
+    AnalyticsTypes,
+    ErrorSourceEnum,
+    LogActionEnum,
     analytics,
     configService,
-    AnalyticsTypes,
-    getConnectionConfig,
     connectionService,
     errorManager,
-    ErrorSourceEnum,
-    LogActionEnum,
+    getConnectionConfig,
     getProvider,
     linkConnection
 } from '@nangohq/shared';
-import type { ApiKeyCredentials, MessageRowInsert, PostPublicApiKeyAuthorization } from '@nangohq/types';
-import type { LogContext } from '@nangohq/logs';
-import { defaultOperationExpiration, endUserToMeta, flushLogsBuffer, logContextGetter } from '@nangohq/logs';
-import { hmacCheck } from '../../utils/hmac.js';
+import { metrics, stringifyError, zodErrorToHTTP } from '@nangohq/utils';
+
+import { connectionCredential, connectionIdSchema, providerConfigKeySchema } from '../../helpers/validation.js';
 import {
     connectionCreated as connectionCreatedHook,
     connectionCreationFailed as connectionCreationFailedHook,
     testConnectionCredentials
 } from '../../hooks/hooks.js';
-import { connectionCredential, connectionIdSchema, providerConfigKeySchema } from '../../helpers/validation.js';
-import db from '@nangohq/database';
+import { asyncWrapper } from '../../utils/asyncWrapper.js';
 import { errorRestrictConnectionId, isIntegrationAllowed } from '../../utils/auth.js';
+import { hmacCheck } from '../../utils/hmac.js';
+
+import type { LogContext } from '@nangohq/logs';
+import type { ApiKeyCredentials, PostPublicApiKeyAuthorization } from '@nangohq/types';
+import type { NextFunction } from 'express';
 
 const bodyValidation = z
     .object({
@@ -154,19 +157,14 @@ export const postPublicApiKeyAuthorization = asyncWrapper<PostPublicApiKeyAuthor
             apiKey
         };
 
-        const connectionResponse = await testConnectionCredentials({ config, connectionConfig, connectionId, credentials, provider });
+        const connectionResponse = await testConnectionCredentials({ config, connectionConfig, connectionId, credentials, provider, logCtx });
         if (connectionResponse.isErr()) {
-            if ('logs' in connectionResponse.error.payload) {
-                await flushLogsBuffer(connectionResponse.error.payload['logs'] as MessageRowInsert[], logCtx);
-            }
-            void logCtx.error('Provided credentials are invalid', { provider: config.provider });
+            void logCtx.error('Provided credentials are invalid');
             await logCtx.failed();
             res.status(400).send({ error: { code: 'connection_test_failed', message: connectionResponse.error.message } });
             return;
         }
 
-        await flushLogsBuffer(connectionResponse.value.logs, logCtx);
-
         const [updatedConnection] = await connectionService.upsertAuthConnection({
             connectionId,
             providerConfigKey,
diff --git a/packages/server/lib/controllers/auth/postBasic.ts b/packages/server/lib/controllers/auth/postBasic.ts
index e5acfe6264..2c0b8fd3ca 100644
--- a/packages/server/lib/controllers/auth/postBasic.ts
+++ b/packages/server/lib/controllers/auth/postBasic.ts
@@ -14,9 +14,9 @@ import {
     getProvider,
     linkConnection
 } from '@nangohq/shared';
-import type { BasicApiCredentials, MessageRowInsert, PostPublicBasicAuthorization } from '@nangohq/types';
+import type { BasicApiCredentials, PostPublicBasicAuthorization } from '@nangohq/types';
 import type { LogContext } from '@nangohq/logs';
-import { defaultOperationExpiration, endUserToMeta, flushLogsBuffer, logContextGetter } from '@nangohq/logs';
+import { defaultOperationExpiration, endUserToMeta, logContextGetter } from '@nangohq/logs';
 import { hmacCheck } from '../../utils/hmac.js';
 import {
     connectionCreated as connectionCreatedHook,
@@ -156,19 +156,14 @@ export const postPublicBasicAuthorization = asyncWrapper<PostPublicBasicAuthoriz
             password
         };
 
-        const connectionResponse = await testConnectionCredentials({ config, connectionConfig, connectionId, credentials, provider });
+        const connectionResponse = await testConnectionCredentials({ config, connectionConfig, connectionId, credentials, provider, logCtx });
         if (connectionResponse.isErr()) {
-            if ('logs' in connectionResponse.error.payload) {
-                await flushLogsBuffer(connectionResponse.error.payload['logs'] as MessageRowInsert[], logCtx);
-            }
-            void logCtx.error('Provided credentials are invalid', { provider: config.provider });
+            void logCtx.error('Provided credentials are invalid');
             await logCtx.failed();
             res.status(400).send({ error: { code: 'connection_test_failed', message: connectionResponse.error.message } });
             return;
         }
 
-        await flushLogsBuffer(connectionResponse.value.logs, logCtx);
-
         const [updatedConnection] = await connectionService.upsertAuthConnection({
             connectionId,
             providerConfigKey,
diff --git a/packages/server/lib/controllers/auth/postJwt.ts b/packages/server/lib/controllers/auth/postJwt.ts
index 307bce5588..f89cd0ac32 100644
--- a/packages/server/lib/controllers/auth/postJwt.ts
+++ b/packages/server/lib/controllers/auth/postJwt.ts
@@ -1,7 +1,7 @@
 import { z } from 'zod';
 
 import db from '@nangohq/database';
-import { defaultOperationExpiration, endUserToMeta, flushLogsBuffer, logContextGetter } from '@nangohq/logs';
+import { defaultOperationExpiration, endUserToMeta, logContextGetter } from '@nangohq/logs';
 import {
     AnalyticsTypes,
     ErrorSourceEnum,
@@ -28,7 +28,7 @@ import { errorRestrictConnectionId, isIntegrationAllowed } from '../../utils/aut
 import { hmacCheck } from '../../utils/hmac.js';
 
 import type { LogContext } from '@nangohq/logs';
-import type { MessageRowInsert, PostPublicJwtAuthorization, ProviderJwt } from '@nangohq/types';
+import type { PostPublicJwtAuthorization, ProviderJwt } from '@nangohq/types';
 import type { NextFunction } from 'express';
 
 const bodyValidation = z
@@ -173,13 +173,9 @@ export const postPublicJwtAuthorization = asyncWrapper<PostPublicJwtAuthorizatio
         }
 
         const credentials = create.value;
-
-        const connectionResponse = await testConnectionCredentials({ config, connectionConfig, connectionId, credentials, provider });
+        const connectionResponse = await testConnectionCredentials({ config, connectionConfig, connectionId, credentials, provider, logCtx });
         if (connectionResponse.isErr()) {
-            if ('logs' in connectionResponse.error.payload) {
-                await flushLogsBuffer(connectionResponse.error.payload['logs'] as MessageRowInsert[], logCtx);
-            }
-            void logCtx.error('Provided credentials are invalid', { provider: config.provider });
+            void logCtx.error('Provided credentials are invalid');
             await logCtx.failed();
 
             errorManager.errResFromNangoErr(res, connectionResponse.error);
@@ -187,8 +183,6 @@ export const postPublicJwtAuthorization = asyncWrapper<PostPublicJwtAuthorizatio
             return;
         }
 
-        await flushLogsBuffer(connectionResponse.value.logs, logCtx);
-
         const [updatedConnection] = await connectionService.upsertAuthConnection({
             connectionId,
             providerConfigKey,
diff --git a/packages/server/lib/controllers/auth/postSignature.ts b/packages/server/lib/controllers/auth/postSignature.ts
index cd7ea502c3..6c97bf84ec 100644
--- a/packages/server/lib/controllers/auth/postSignature.ts
+++ b/packages/server/lib/controllers/auth/postSignature.ts
@@ -14,9 +14,9 @@ import {
     getProvider,
     linkConnection
 } from '@nangohq/shared';
-import type { MessageRowInsert, PostPublicSignatureAuthorization, ProviderSignature } from '@nangohq/types';
+import type { PostPublicSignatureAuthorization, ProviderSignature } from '@nangohq/types';
 import type { LogContext } from '@nangohq/logs';
-import { defaultOperationExpiration, endUserToMeta, flushLogsBuffer, logContextGetter } from '@nangohq/logs';
+import { defaultOperationExpiration, endUserToMeta, logContextGetter } from '@nangohq/logs';
 import { hmacCheck } from '../../utils/hmac.js';
 import {
     connectionCreated as connectionCreatedHook,
@@ -163,12 +163,9 @@ export const postPublicSignatureAuthorization = asyncWrapper<PostPublicSignature
             return;
         }
 
-        const connectionResponse = await testConnectionCredentials({ config, connectionConfig, connectionId, credentials, provider });
+        const connectionResponse = await testConnectionCredentials({ config, connectionConfig, connectionId, credentials, provider, logCtx });
         if (connectionResponse.isErr()) {
-            if ('logs' in connectionResponse.error.payload) {
-                await flushLogsBuffer(connectionResponse.error.payload['logs'] as MessageRowInsert[], logCtx);
-            }
-            void logCtx.error('Provided credentials are invalid', { provider: config.provider });
+            void logCtx.error('Provided credentials are invalid');
             await logCtx.failed();
 
             errorManager.errResFromNangoErr(res, connectionResponse.error);
@@ -176,8 +173,6 @@ export const postPublicSignatureAuthorization = asyncWrapper<PostPublicSignature
             return;
         }
 
-        await flushLogsBuffer(connectionResponse.value.logs, logCtx);
-
         const [updatedConnection] = await connectionService.upsertAuthConnection({
             connectionId,
             providerConfigKey,
diff --git a/packages/server/lib/controllers/auth/postTba.ts b/packages/server/lib/controllers/auth/postTba.ts
index d87d2c3d27..de1a4ee8cd 100644
--- a/packages/server/lib/controllers/auth/postTba.ts
+++ b/packages/server/lib/controllers/auth/postTba.ts
@@ -13,9 +13,9 @@ import {
     LogActionEnum,
     linkConnection
 } from '@nangohq/shared';
-import type { TbaCredentials, PostPublicTbaAuthorization, MessageRowInsert } from '@nangohq/types';
+import type { TbaCredentials, PostPublicTbaAuthorization } from '@nangohq/types';
 import type { LogContext } from '@nangohq/logs';
-import { defaultOperationExpiration, endUserToMeta, flushLogsBuffer, logContextGetter } from '@nangohq/logs';
+import { defaultOperationExpiration, endUserToMeta, logContextGetter } from '@nangohq/logs';
 import { hmacCheck } from '../../utils/hmac.js';
 import {
     connectionCreated as connectionCreatedHook,
@@ -182,12 +182,9 @@ export const postPublicTbaAuthorization = asyncWrapper<PostPublicTbaAuthorizatio
             }
         }
 
-        const connectionResponse = await testConnectionCredentials({ config, connectionConfig, connectionId, credentials: tbaCredentials, provider });
+        const connectionResponse = await testConnectionCredentials({ config, connectionConfig, connectionId, credentials: tbaCredentials, provider, logCtx });
         if (connectionResponse.isErr()) {
-            if ('logs' in connectionResponse.error.payload) {
-                await flushLogsBuffer(connectionResponse.error.payload['logs'] as MessageRowInsert[], logCtx);
-            }
-            void logCtx.error('Provided credentials are invalid', { provider: config.provider });
+            void logCtx.error('Provided credentials are invalid');
             await logCtx.failed();
 
             res.send({
@@ -197,8 +194,6 @@ export const postPublicTbaAuthorization = asyncWrapper<PostPublicTbaAuthorizatio
             return;
         }
 
-        await flushLogsBuffer(connectionResponse.value.logs, logCtx);
-
         const [updatedConnection] = await connectionService.upsertAuthConnection({
             connectionId,
             providerConfigKey,
diff --git a/packages/server/lib/hooks/hooks.ts b/packages/server/lib/hooks/hooks.ts
index feb80da62a..5598c9366e 100644
--- a/packages/server/lib/hooks/hooks.ts
+++ b/packages/server/lib/hooks/hooks.ts
@@ -21,7 +21,7 @@ import { slackService } from '../services/slack.js';
 import { postConnectionCreation } from './connection/on/connection-created.js';
 import postConnection from './connection/post-connection.js';
 
-import type { LogContext, LogContextGetter } from '@nangohq/logs';
+import type { LogContext, LogContextGetter, LogContextStateless } from '@nangohq/logs';
 import type { ApiKeyCredentials, BasicApiCredentials, Config } from '@nangohq/shared';
 import type {
     ApplicationConstructedProxyConfiguration,
@@ -32,7 +32,6 @@ import type {
     IntegrationConfig,
     InternalProxyConfiguration,
     JwtCredentials,
-    MessageRowInsert,
     Provider,
     RecentlyCreatedConnection,
     RecentlyFailedConnection,
@@ -80,27 +79,21 @@ export async function testConnectionCredentials({
     connectionConfig,
     connectionId,
     credentials,
-    provider
+    provider,
+    logCtx
 }: {
     config: Config;
     connectionConfig: ConnectionConfig;
     connectionId: string;
     credentials: ApiKeyCredentials | BasicApiCredentials | TbaCredentials | JwtCredentials | SignatureCredentials;
     provider: Provider;
-}): Promise<Result<{ logs: MessageRowInsert[]; tested: boolean }, NangoError>> {
-    const logs: MessageRowInsert[] = [
-        {
-            type: 'log',
-            level: 'info',
-            message: 'Running automatic credentials verification via verification script',
-            createdAt: new Date().toISOString()
-        }
-    ];
-
+    logCtx: LogContextStateless;
+}): Promise<Result<{ tested: boolean }, NangoError>> {
     try {
         if (provider.credentials_verification_script) {
+            void logCtx.info('Running automatic credentials verification via verification script');
             await executeVerificationScript(config, credentials, connectionId, connectionConfig);
-            return Ok({ logs, tested: true });
+            return Ok({ tested: true });
         }
 
         if (provider.proxy?.verification) {
@@ -109,20 +102,16 @@ export async function testConnectionCredentials({
                 provider,
                 credentials,
                 connectionId,
-                connectionConfig
+                connectionConfig,
+                logCtx
             });
             return result;
         }
-        return Ok({ logs: [], tested: false });
+        return Ok({ tested: false });
     } catch (err) {
-        logs.push({
-            type: 'log',
-            level: 'error',
-            message: 'Connection test verification failed',
-            createdAt: new Date().toISOString()
-        });
+        void logCtx.error('Connection test verification failed');
 
-        return Err(new NangoError('connection_test_failed', { err, logs }));
+        return Err(new NangoError('connection_test_failed', { err }));
     }
 }
 
@@ -275,18 +264,20 @@ export async function credentialsTest({
     provider,
     credentials,
     connectionId,
-    connectionConfig
+    connectionConfig,
+    logCtx
 }: {
     config: Config;
     provider: Provider;
     credentials: ApiKeyCredentials | BasicApiCredentials | TbaCredentials | JwtCredentials | SignatureCredentials;
     connectionId: string;
     connectionConfig: ConnectionConfig;
-}): Promise<Result<{ logs: MessageRowInsert[]; tested: boolean }, NangoError>> {
+    logCtx: LogContextStateless;
+}): Promise<Result<{ tested: boolean }, NangoError>> {
     const providerVerification = provider?.proxy?.verification;
 
     if (!providerVerification?.endpoints?.length) {
-        return Ok({ logs: [], tested: false });
+        return Ok({ tested: false });
     }
 
     const active = tracer.scope().active();
@@ -325,9 +316,7 @@ export async function credentialsTest({
         refresh_exhausted: false
     };
 
-    const logs: MessageRowInsert[] = [
-        { type: 'log', level: 'info', message: `Running automatic credentials verification`, createdAt: new Date().toISOString() }
-    ];
+    void logCtx.info(`Running automatic credentials verification`);
 
     const internalConfig: InternalProxyConfiguration = {
         providerName: config.provider
@@ -358,7 +347,7 @@ export async function credentialsTest({
             const proxyConfig = getProxyConfiguration({ externalConfig: configBody, internalConfig }).unwrap();
             const proxy = new ProxyRequest({
                 logger: (msg) => {
-                    logs.push(msg);
+                    void logCtx.log(msg);
                 },
                 proxyConfig,
                 getConnection: () => {
@@ -369,14 +358,14 @@ export async function credentialsTest({
             const response = (await proxy.request()).unwrap();
 
             if (response.status && response.status >= 200 && response.status < 300) {
-                return Ok({ logs, tested: true });
+                return Ok({ tested: true });
             }
         } catch {
             // Already covered
         }
     }
 
-    const error = new NangoError('connection_test_failed', { logs });
+    const error = new NangoError('connection_test_failed');
     span.setTag('error', error);
     span.finish();
     return Err(error);
diff --git a/packages/shared/lib/clients/oauth2.client.ts b/packages/shared/lib/clients/oauth2.client.ts
index 56d7740b52..dc0d6e2def 100644
--- a/packages/shared/lib/clients/oauth2.client.ts
+++ b/packages/shared/lib/clients/oauth2.client.ts
@@ -1,15 +1,19 @@
-import type { Config as ProviderConfig, OAuth2Credentials } from '../models/index.js';
-import type { DBConnectionDecrypted, Provider, ProviderOAuth2 } from '@nangohq/types';
-import type { AccessToken, ModuleOptions, WreckHttpOptions } from 'simple-oauth2';
+import Boom from '@hapi/boom';
 import { AuthorizationCode } from 'simple-oauth2';
-import connectionsManager from '../services/connection.service.js';
-import type { ServiceResponse } from '../models/Generic.js';
+
+import { httpAgent, httpsAgent, redactHeaders } from '@nangohq/utils';
+
 import { LogActionEnum } from '../models/Telemetry.js';
-import { interpolateString, encodeParameters } from '../utils/utils.js';
-import Boom from '@hapi/boom';
+import connectionsManager from '../services/connection.service.js';
 import { NangoError } from '../utils/error.js';
 import errorManager, { ErrorSourceEnum } from '../utils/error.manager.js';
-import { httpAgent, httpsAgent } from '@nangohq/utils';
+import { encodeParameters, interpolateString } from '../utils/utils.js';
+
+import type { ServiceResponse } from '../models/Generic.js';
+import type { Config as ProviderConfig, OAuth2Credentials } from '../models/index.js';
+import type { LogContextStateless } from '@nangohq/logs';
+import type { DBConnectionDecrypted, Provider, ProviderOAuth2 } from '@nangohq/types';
+import type { AccessToken, ModuleOptions, WreckHttpOptions } from 'simple-oauth2';
 import type { Merge } from 'type-fest';
 
 // we specify these agents as getters so that they aren't cloned by simple-oauth2,
@@ -64,11 +68,17 @@ export function getSimpleOAuth2ClientConfig(
     };
 }
 
-export async function getFreshOAuth2Credentials(
-    connection: DBConnectionDecrypted,
-    config: ProviderConfig,
-    provider: ProviderOAuth2
-): Promise<ServiceResponse<OAuth2Credentials>> {
+export async function getFreshOAuth2Credentials({
+    connection,
+    config,
+    provider,
+    logCtx
+}: {
+    connection: DBConnectionDecrypted;
+    config: ProviderConfig;
+    provider: ProviderOAuth2;
+    logCtx: LogContextStateless;
+}): Promise<ServiceResponse<OAuth2Credentials>> {
     const credentials = connection.credentials as OAuth2Credentials;
     if (credentials.config_override && credentials.config_override.client_id && credentials.config_override.client_secret) {
         config = {
@@ -100,9 +110,20 @@ export async function getFreshOAuth2Credentials(
     }
 
     let rawNewAccessToken: AccessToken;
+    const createdAt = new Date();
+    const url = `${simpleOAuth2ClientConfig.auth.tokenHost}${simpleOAuth2ClientConfig.auth.tokenPath}`;
 
     try {
         rawNewAccessToken = await oldAccessToken.refresh(additionalParams);
+        void logCtx.http(`POST ${url}`, {
+            createdAt,
+            request: {
+                method: 'POST',
+                url,
+                headers: redactHeaders({ headers: simpleOAuth2ClientConfig.http.headers, valuesToFilter: [config.oauth_client_secret] })
+            },
+            response: { code: 200, headers: {} }
+        });
     } catch (err) {
         let nangoErr: NangoError;
         if (Boom.isBoom(err)) {
@@ -111,12 +132,28 @@ export async function getFreshOAuth2Credentials(
                 errorPayload = err.data.payload;
             }
             const payload = {
-                external_message: err.message,
-                external_request_details: err.output,
                 dataMessage: errorPayload instanceof Buffer ? errorPayload.toString() : errorPayload
             };
+            void logCtx.http(`POST ${url}`, {
+                level: 'error',
+                createdAt,
+                request: {
+                    method: 'POST',
+                    url,
+                    headers: redactHeaders({ headers: simpleOAuth2ClientConfig.http.headers, valuesToFilter: [config.oauth_client_secret] })
+                },
+                response: { code: err.output.statusCode, headers: err.output.headers as Record<string, any> },
+                meta: { body: errorPayload instanceof Buffer ? errorPayload.toString() : errorPayload }
+            });
             nangoErr = new NangoError(`refresh_token_external_error`, payload);
         } else {
+            void logCtx.http(`POST ${url}`, {
+                level: 'error',
+                createdAt,
+                request: { method: 'POST', url, headers: {} },
+                response: undefined,
+                error: err
+            });
             nangoErr = new NangoError(`refresh_token_external_error`, { message: err instanceof Error ? err.message : 'unknown Error' });
         }
 
diff --git a/packages/shared/lib/services/connection.service.ts b/packages/shared/lib/services/connection.service.ts
index 9c265516f0..98e561a395 100644
--- a/packages/shared/lib/services/connection.service.ts
+++ b/packages/shared/lib/services/connection.service.ts
@@ -50,7 +50,7 @@ import type { ServiceResponse } from '../models/Generic.js';
 import type { AuthCredentials, Config as ProviderConfig, OAuth1Credentials } from '../models/index.js';
 import type { SlackService } from './notification/slack.service.js';
 import type { Knex } from '@nangohq/database';
-import type { LogContext } from '@nangohq/logs';
+import type { LogContext, LogContextStateless } from '@nangohq/logs';
 import type {
     AuthModeType,
     BillCredentials,
@@ -1541,11 +1541,17 @@ class ConnectionService {
         return false;
     }
 
-    public async getNewCredentials(
-        connection: DBConnectionDecrypted,
-        providerConfig: ProviderConfig,
-        provider: Provider
-    ): Promise<
+    public async getNewCredentials({
+        connection,
+        providerConfig,
+        provider,
+        logCtx
+    }: {
+        connection: DBConnectionDecrypted;
+        providerConfig: ProviderConfig;
+        provider: Provider;
+        logCtx: LogContextStateless;
+    }): Promise<
         ServiceResponse<
             | OAuth2Credentials
             | OAuth2ClientCredentials
@@ -1647,7 +1653,11 @@ class ConnectionService {
 
             return { success: true, error: null, response: credentials };
         } else {
-            const { success, error, response: creds } = await getFreshOAuth2Credentials(connection, providerConfig, provider as ProviderOAuth2);
+            const {
+                success,
+                error,
+                response: creds
+            } = await getFreshOAuth2Credentials({ connection, config: providerConfig, provider: provider as ProviderOAuth2, logCtx });
 
             return { success, error, response: success ? (creds as OAuth2Credentials) : null };
         }
diff --git a/packages/shared/lib/services/connections/credentials/refresh.ts b/packages/shared/lib/services/connections/credentials/refresh.ts
index cc7955c405..39bb0614e8 100644
--- a/packages/shared/lib/services/connections/credentials/refresh.ts
+++ b/packages/shared/lib/services/connections/credentials/refresh.ts
@@ -14,14 +14,13 @@ import type { Config } from '../../../models';
 import type { Config as ProviderConfig } from '../../../models/index.js';
 import type { NangoInternalError } from '../../../utils/error.js';
 import type { Lock } from '@nangohq/kvstore';
-import type { LogContext, LogContextGetter } from '@nangohq/logs';
+import type { LogContext, LogContextGetter, LogContextStateless } from '@nangohq/logs';
 import type {
     ConnectionConfig,
     DBConnectionDecrypted,
     DBEnvironment,
     DBTeam,
     IntegrationConfig,
-    MessageRowInsert,
     Provider,
     RefreshableCredentials,
     RefreshableProvider,
@@ -55,7 +54,8 @@ interface RefreshProps {
               credentials: TestableCredentials;
               connectionId: string;
               connectionConfig: ConnectionConfig;
-          }) => Promise<Result<{ logs: MessageRowInsert[]; tested: boolean }, NangoError>>)
+              logCtx: LogContextStateless;
+          }) => Promise<Result<{ tested: boolean }, NangoError>>)
         | undefined;
 }
 
@@ -162,13 +162,15 @@ async function refreshCredentials(
     { environment, integration, account, connection: oldConnection, instantRefresh, logContextGetter, onRefreshFailed, onRefreshSuccess }: RefreshProps,
     provider: RefreshableProvider
 ): Promise<Result<DBConnectionDecrypted, NangoError>> {
+    const logsBuffer = logContextGetter.getBuffer({ accountId: account.id });
     const refreshRes = await refreshCredentialsIfNeeded({
         connectionId: oldConnection.connection_id,
         environmentId: environment.id,
         providerConfig: integration as ProviderConfig,
         provider: provider,
         environment_id: environment.id,
-        instantRefresh
+        instantRefresh,
+        logCtx: logsBuffer
     });
 
     if (refreshRes.isErr()) {
@@ -182,6 +184,7 @@ async function refreshCredentials(
                 connection: { id: oldConnection.id, name: oldConnection.connection_id }
             }
         );
+        logCtx.merge(logsBuffer);
 
         metrics.increment(metrics.Types.REFRESH_CONNECTIONS_FAILED);
         void logCtx.error('Failed to refresh credentials', err);
@@ -234,12 +237,14 @@ async function testCredentials(
         return Ok(oldConnection);
     }
 
+    const logsBuffer = logContextGetter.getBuffer({ accountId: account.id });
     const result = await connectionTestHook({
         config: integration as ProviderConfig,
         provider,
         connectionConfig: oldConnection.connection_config,
         connectionId: oldConnection.connection_id,
-        credentials: oldConnection.credentials as TestableCredentials
+        credentials: oldConnection.credentials as TestableCredentials,
+        logCtx: logsBuffer
     });
 
     if (result.isErr()) {
@@ -252,13 +257,7 @@ async function testCredentials(
                 connection: { id: oldConnection.id, name: oldConnection.connection_id }
             }
         );
-        if ('logs' in result.error.payload) {
-            await Promise.all(
-                (result.error.payload['logs'] as MessageRowInsert[]).map(async (log) => {
-                    await logCtx.log(log);
-                })
-            );
-        }
+        logCtx.merge(logsBuffer);
 
         void logCtx.error('Failed to verify connection', result.error);
         await logCtx.failed();
@@ -315,7 +314,8 @@ async function refreshCredentialsIfNeeded({
     providerConfig,
     provider,
     environment_id,
-    instantRefresh = false
+    instantRefresh = false,
+    logCtx
 }: {
     connectionId: string;
     environmentId: number;
@@ -323,6 +323,7 @@ async function refreshCredentialsIfNeeded({
     provider: RefreshableProvider;
     environment_id: number;
     instantRefresh?: boolean;
+    logCtx: LogContextStateless;
 }): Promise<Result<{ connection: DBConnectionDecrypted; refreshed: boolean; credentials: RefreshableCredentials }, NangoInternalError>> {
     const providerConfigKey = providerConfig.unique_key;
     const locking = await getLocking();
@@ -393,7 +394,11 @@ async function refreshCredentialsIfNeeded({
             throw err;
         }
 
-        const { success, error, response: newCredentials } = await connectionService.getNewCredentials(connectionToRefresh, providerConfig, provider);
+        const {
+            success,
+            error,
+            response: newCredentials
+        } = await connectionService.getNewCredentials({ connection: connectionToRefresh, providerConfig, provider, logCtx });
         if (!success || !newCredentials) {
             return Err(error!);
         }


Reviews:
user: login: TBonnin
body: > We now have a buffer class that acts like a regular logCtx so they used without knowing if it's a buffer or not

genius

state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-25T13:39:54Z
user: login: bodinsamuel
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T14:17:34Z
user: login: TBonnin
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-25T14:26:00Z
user: login: bodinsamuel
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T14:31:33Z
user: login: TBonnin
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-03-25T14:40:20Z
user: login: bodinsamuel
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-25T15:09:45Z
user: login: TBonnin
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-25T15:14:12Z

Review comments:
diff_hunk: @@ -0,0 +1,56 @@
+import { metrics, report } from '@nangohq/utils';
+
+import { errorToDocument } from './formatters.js';
+import { getFormattedMessage } from './models/helpers.js';
+import { createMessage } from './models/messages.js';
+import { logLevelToLogger, logger } from './utils.js';
+
+import type { MaybePromise, MessageRowInsert } from '@nangohq/types';
+
+interface Props {
+    operationId: string;
+    accountId?: number | undefined;
+    dryRun: boolean;
+    logToConsole: boolean;
+}
+
+export abstract class LogTransportAbstract {
+    abstract log(msg: MessageRowInsert, { accountId, dryRun, logToConsole }: Props): MaybePromise<boolean>;
+}
+
+export class ESTransport implements LogTransportAbstract {
+    async log(data: MessageRowInsert, { operationId, accountId, dryRun, logToConsole }: Props): Promise<boolean> {
+        if (data.error && data.error.constructor.name !== 'Object') {
+            data.error = errorToDocument(data.error);
+        }
+
+        if (logToConsole) {
+            const obj: Record<string, any> = {};
+            if (data.error) obj['error'] = data.error;
+            if (data.meta) obj['meta'] = data.meta;
+            logger[logLevelToLogger[data.level]](`${dryRun ? '[dry] ' : ''}log: ${data.message}`, Object.keys(obj).length > 0 ? obj : undefined);
+        }
+        if (dryRun) {
+            return true;
+        }
+
+        const start = Date.now();
+        try {
+            await createMessage(getFormattedMessage({ ...data, parentId: operationId }));
+            return true;
+        } catch (err) {
+            report(new Error('failed_to_insert_in_es', { cause: err }));
+            return false;
+        } finally {
+            metrics.duration(metrics.Types.LOGS_LOG, Date.now() - start, { accountId: accountId as number });
+        }
+    }
+}
+
+export class BufferTransport implements LogTransportAbstract {
+    buffer: MessageRowInsert[] = [];
+    log(msg: MessageRowInsert): boolean {
+        this.buffer.push(msg);
+        return true;
+    }
+}
path: packages/logs/lib/transport.ts
user: login: TBonnin
body: 🫶 
created_at: 2025-03-25T13:08:39Z
author_association: COLLABORATOR
diff_hunk: @@ -182,6 +186,7 @@ async function refreshCredentials(
                 connection: { id: oldConnection.id, name: oldConnection.connection_id }
             }
         );
+        void flushLogsBuffer((logsBuffer.transport as BufferTransport).buffer, logCtx);
path: packages/shared/lib/services/connections/credentials/refresh.ts
user: login: TBonnin
body: should flushing be abstracted and happen automatically on` logCtx.failed/success` if transport is Buffer. With maybe a `forceFlush` method in `LogContextStateless` to manually flush buffer if needed
created_at: 2025-03-25T13:39:21Z
author_association: COLLABORATOR
diff_hunk: @@ -182,6 +186,7 @@ async function refreshCredentials(
                 connection: { id: oldConnection.id, name: oldConnection.connection_id }
             }
         );
+        void flushLogsBuffer((logsBuffer.transport as BufferTransport).buffer, logCtx);
path: packages/shared/lib/services/connections/credentials/refresh.ts
user: login: bodinsamuel
body: we would need to attach/pass the buffer to the actual logCtx that contains the proper transport.
Maybe we could have a `logCtx.merge(logCtx)` and the transport decide on what to do: 
- if buffer, it saves more buffer
- if es, it flushes everything
Wdyt?
created_at: 2025-03-25T14:17:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -182,6 +186,7 @@ async function refreshCredentials(
                 connection: { id: oldConnection.id, name: oldConnection.connection_id }
             }
         );
+        void flushLogsBuffer((logsBuffer.transport as BufferTransport).buffer, logCtx);
path: packages/shared/lib/services/connections/credentials/refresh.ts
user: login: TBonnin
body: I am not sure why we create two logCtx in this case. Can we not reuse the buffer one?
created_at: 2025-03-25T14:26:00Z
author_association: COLLABORATOR
diff_hunk: @@ -182,6 +186,7 @@ async function refreshCredentials(
                 connection: { id: oldConnection.id, name: oldConnection.connection_id }
             }
         );
+        void flushLogsBuffer((logsBuffer.transport as BufferTransport).buffer, logCtx);
path: packages/shared/lib/services/connections/credentials/refresh.ts
user: login: bodinsamuel
body: rn the buffer doesn't have the capability to create an operation, just for simplicity's sake, it's just buffering the logs but we could improve for sure
created_at: 2025-03-25T14:31:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -182,6 +186,7 @@ async function refreshCredentials(
                 connection: { id: oldConnection.id, name: oldConnection.connection_id }
             }
         );
+        void flushLogsBuffer((logsBuffer.transport as BufferTransport).buffer, logCtx);
path: packages/shared/lib/services/connections/credentials/refresh.ts
user: login: TBonnin
body: I see. a merge `logCtx.merge(logsBuffer)` could be a good idea indeed. this or passing a buffer to `logContextGetter.create`
created_at: 2025-03-25T14:40:20Z
author_association: COLLABORATOR
diff_hunk: @@ -182,6 +186,7 @@ async function refreshCredentials(
                 connection: { id: oldConnection.id, name: oldConnection.connection_id }
             }
         );
+        void flushLogsBuffer((logsBuffer.transport as BufferTransport).buffer, logCtx);
path: packages/shared/lib/services/connections/credentials/refresh.ts
user: login: bodinsamuel
body: Changed in [450e17a](https://github.com/NangoHQ/nango/pull/3765/commits/450e17a7c5a539bf0937cd5c377c214e3a93f359)
created_at: 2025-03-25T15:09:45Z
author_association: CONTRIBUTOR

PR comments:
user: login: linear[bot]
created_at: 2025-03-24T22:43:55Z
body: <p><a href="https://linear.app/nango/issue/NAN-2975/investigate-failed-refresh">NAN-2975 Investigate failed refresh</a></p>
author_association: NONE
user: login: khaliqgant
created_at: 2025-03-25T04:28:14Z
body: > simple-oauth2 is hiding a lot of stuff, it's really annoying

I agree. I was tempted to rip it out more times than I can count but ended up not. Maybe as a start worth forking and making some tweaks? It is very much online with our offering 
author_association: CONTRIBUTOR
user: login: bodinsamuel
created_at: 2025-03-25T09:28:19Z
body: > I agree. I was tempted to rip it out more times than I can count but ended up not. Maybe as a start worth forking and making some tweaks? It is very much online with our offering

We talked about that with Bastien a long time ago, but I took a long dive in the code and the whole library is probably 2-3 functions. The rest is plumbing and error handling.  
author_association: CONTRIBUTOR