[twenty-server] no floating promises lint rule (#20499)

## Introduction

That's an audit + RFC

## Fire-and-forget (`void`) -- Intentional, correct

These are telemetry, metrics, and audit logging in hot paths or
non-critical contexts. `void` is the right choice.

| File | What was voided |
|---|---|
| `sign-in-up.service.ts` | `metricsService.incrementCounter` (sign-up
metric) + `auditService.insertWorkspaceEvent` (workspace created) |
| `use-graphql-error-handler.hook.ts` | 5x
`metricsService.incrementCounter` (GraphQL operation metrics) |
| `bullmq.driver.ts` | 2x `metricsService.incrementCounter` (job
completed/failed metrics) |
| `call-webhook.job.ts` | 2x `auditService.insertWorkspaceEvent` + 1x
`metricsService.incrementCounter` |
| `custom-domain-manager.service.ts` | `analytics.insertWorkspaceEvent`
(domain activation event) |
| `logic-function-executor.service.ts` |
`auditService.insertWorkspaceEvent` (function execution) |
| `workflow-runner.workspace-service.ts` |
`metricsService.incrementCounter` (throttle metric) |
| `cleaner.workspace-service.ts` | `metricsService.incrementCounter`
(deleted workspace metric) |
| `stream-agent-chat.job.ts` | Detached IIFE for streaming chunks
(intentional concurrent pipeline) |
| `workspace-auth-context.middleware.ts` |
`withWorkspaceAuthContext(...)` (AsyncLocalStorage, returns void anyway)
|

## Top-level script entry points (`void bootstrap()`)

These are module-level calls where the promise has no consumer. `void`
makes the lint rule happy and documents the intent.

| File | What changed |
|---|---|
| `main.ts` | `void bootstrap()` |
| `command.ts` | `void bootstrap()` |
| `queue-worker.ts` | `void bootstrap()` |
| `truncate-db.ts` | `void dropSchemasSequentially()` |
| `codegen/index.ts` | `void generateTests(forceArg)` |

## Now properly awaited -- Real bug fixes

These were floating promises that could silently fail, lose data, or
cause race conditions.

| File | What was fixed |
|---|---|
| `billing-sync-plans-data.command.ts` | `meters.map(async ...)` wrapped
in `Promise.all` -- was returning before upserts finished |
| `cache-storage.service.ts` | `setAdd` and `setPop` had `.then()`
chains that weren't returned/awaited |
| `create-audit-log-from-internal-event.ts` | 4x
`auditService.createObjectEvent` now awaited inside a job |
| `cleaner.workspace-service.ts` | 2x `emailService.send(...)` now
awaited -- emails could silently fail |
| `agent-async-executor.service.ts` | `calculateAndBillUsage` +
`billNativeWebSearchUsage` in `finally` block now awaited |
| `repair-tool-call.util.ts` | `calculateAndBillUsage` now awaited |
| `agent-title-generation.service.ts` | `calculateAndBillUsage` now
awaited |
| `chat-execution.service.ts` | `billNativeWebSearchUsage` now awaited |
| `ai-generate-text.controller.ts` | `calculateAndBillUsage` in
`finally` block now awaited |
| `agent-turn.resolver.ts` | `messageQueueService.add(...)` now awaited
|
| `command.ts` | `app.close()` now awaited (was exiting before graceful
shutdown) |
| `i18n.service.ts` | `loadTranslations()` in `onModuleInit` now awaited
|
| `workspace-query-hook.explorer.ts` | `explore()` in `onModuleInit` now
awaited |
| `message-queue.explorer.ts` | `handleProcessorGroupCollection` in
`onModuleInit` now awaited |
| `ai-billing.service.spec.ts` | Test now properly `await`s the async
call |
| `messaging-messages-import.service.spec.ts` | `expect(...)` now
properly `await`ed for async assertion |
| `archive.finalize()` (3 files) | Voided -- promise resolution already
handled by `pipeline()` / `on('end')` |

## Impersonation & security audit trail -- Upgraded from `void` to
`await`

These were previously fire-and-forget but are
security/compliance-critical events that must be reliably persisted.

| File | What was fixed |
|---|---|
| `impersonation.service.ts` | 4x `auditService.insertWorkspaceEvent`
now awaited (impersonation attempt, token generation
attempt/success/failure) |
| `auth.resolver.ts` | 5x `auditService.insertWorkspaceEvent` now
awaited (impersonation token exchange attempt/success/failure at server
and workspace levels) |
| `auth.service.ts` | 2x `analytics.insertWorkspaceEvent` now awaited
(impersonation attempted/issued) |

## Billing audit -- Upgraded from `void` to `await`

Payment events should be reliably persisted for financial/compliance
reporting.

| File | What was fixed |
|---|---|
| `billing-webhook-invoice.service.ts` |
`auditService.insertWorkspaceEvent(PAYMENT_RECEIVED_EVENT)` now awaited
inside Stripe webhook handler |

## Fire-and-forget with proper error handling -- Upgraded from bare
`void`

These remain non-blocking but now catch and log errors instead of
risking unhandled rejections.

| File | What was fixed |
|---|---|
| `logic-function-executor.service.ts` |
`applicationLogsService.writeLogs` now uses `.catch()` instead of bare
`void` -- user-facing logs should surface errors |

## Systemic infrastructure fixes

| File | What was fixed |
|---|---|
| `metrics.service.ts` | `incrementCounter`: Redis cache write
(`metricsCacheService.updateCounter`) now uses `.catch()` internally
instead of raw `await` -- prevents unhandled rejections across all `void
metricsService.incrementCounter(...)` call sites when Redis is unhealthy
|
| `audit.service.ts` | `preventIfDisabled`: made properly `async` with
`await` and consistent `Promise<{ success: boolean }>` return type.
Removed broken `catch` that returned an `AuditException` as a value
(wrong constructor args, unreachable dead code). Removed unused
`AuditException` import |

## Fixed in this session (beyond original PR)

| File | What changed |
|---|---|
| `telemetry.listener.ts` | Removed misleading `Promise.all` + `void`
combo; replaced with simple `for...of` + `void` |
| `message-queue.explorer.ts` | Changed from `void` to `await` so
startup crashes on registration failure |
This commit is contained in:
Paul Rastoin
2026-05-13 12:08:42 +02:00
committed by GitHub
parent 38d9eacff8
commit a159a68e2c
43 changed files with 135 additions and 122 deletions
@@ -35,25 +35,25 @@ export class CreateAuditLogFromInternalEvent {
// Since these are object record events, we use createObjectEvent
if (workspaceEventBatch.name.endsWith('.updated')) {
auditService.createObjectEvent(OBJECT_RECORD_UPDATED_EVENT, {
await auditService.createObjectEvent(OBJECT_RECORD_UPDATED_EVENT, {
...eventProperties,
recordId: eventData.recordId,
objectMetadataId: workspaceEventBatch.objectMetadata.id,
});
} else if (workspaceEventBatch.name.endsWith('.created')) {
auditService.createObjectEvent(OBJECT_RECORD_CREATED_EVENT, {
await auditService.createObjectEvent(OBJECT_RECORD_CREATED_EVENT, {
...eventProperties,
recordId: eventData.recordId,
objectMetadataId: workspaceEventBatch.objectMetadata.id,
});
} else if (workspaceEventBatch.name.endsWith('.deleted')) {
auditService.createObjectEvent(OBJECT_RECORD_DELETED_EVENT, {
await auditService.createObjectEvent(OBJECT_RECORD_DELETED_EVENT, {
...eventProperties,
recordId: eventData.recordId,
objectMetadataId: workspaceEventBatch.objectMetadata.id,
});
} else if (workspaceEventBatch.name.endsWith('.upserted')) {
auditService.createObjectEvent(OBJECT_RECORD_UPSERTED_EVENT, {
await auditService.createObjectEvent(OBJECT_RECORD_UPSERTED_EVENT, {
...eventProperties,
recordId: eventData.recordId,
objectMetadataId: workspaceEventBatch.objectMetadata.id,
@@ -1,10 +1,6 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { ClickHouseService } from 'src/database/clickHouse/clickHouse.service';
import {
AuditException,
AuditExceptionCode,
} from 'src/engine/core-modules/audit/audit.exception';
import {
type TrackEventName,
type TrackEventProperties,
@@ -18,6 +14,8 @@ import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twent
@Injectable()
export class AuditService {
private readonly logger = new Logger(AuditService.name);
constructor(
private readonly twentyConfigService: TwentyConfigService,
private readonly clickHouseService: ClickHouseService,
@@ -82,16 +80,19 @@ export class AuditService {
};
}
private preventIfDisabled(
private async preventIfDisabled(
sendEventOrPageviewFunction: () => Promise<{ success: boolean }>,
) {
): Promise<{ success: boolean }> {
if (!this.twentyConfigService.get('CLICKHOUSE_URL')) {
return { success: true };
}
try {
return sendEventOrPageviewFunction();
} catch (err) {
return new AuditException(err, AuditExceptionCode.INVALID_INPUT);
return await sendEventOrPageviewFunction();
} catch (error) {
this.logger.error('Failed to persist audit event to ClickHouse', error);
return { success: false };
}
}
}
@@ -711,7 +711,7 @@ export class AuthResolver {
userId: impersonatorUserWorkspace.user.id,
});
auditService.insertWorkspaceEvent(MONITORING_EVENT, {
await auditService.insertWorkspaceEvent(MONITORING_EVENT, {
eventName: `${isServerLevelImpersonation ? 'server' : 'workspace'}.impersonation.token_exchange_attempt`,
message: `Impersonation token exchange attempt for ${targetUserEmail} by ${impersonatorUserWorkspace.user.id}`,
});
@@ -722,7 +722,7 @@ export class AuthResolver {
if (isServerLevelImpersonation) {
if (!hasServerLevelImpersonatePermission) {
auditService.insertWorkspaceEvent(MONITORING_EVENT, {
await auditService.insertWorkspaceEvent(MONITORING_EVENT, {
eventName: 'server.impersonation.token_exchange_failed',
message: `Server level impersonation not allowed for ${targetUserEmail} by userId ${impersonatorUserWorkspace.user.id}`,
});
@@ -733,7 +733,7 @@ export class AuthResolver {
);
}
auditService.insertWorkspaceEvent(MONITORING_EVENT, {
await auditService.insertWorkspaceEvent(MONITORING_EVENT, {
eventName: `server.impersonation.token_exchange_success`,
message: `Impersonation token exchanged for ${targetUserEmail} by userId ${impersonatorUserWorkspace.user.id}`,
});
@@ -755,7 +755,7 @@ export class AuthResolver {
});
if (!hasWorkspaceLevelImpersonatePermission) {
auditService.insertWorkspaceEvent(MONITORING_EVENT, {
await auditService.insertWorkspaceEvent(MONITORING_EVENT, {
eventName: 'workspace.impersonation.token_exchange_failed',
message: `Impersonation not allowed for ${targetUserEmail} by userId ${impersonatorUserWorkspace.user.id}`,
});
@@ -765,7 +765,7 @@ export class AuthResolver {
);
}
auditService.insertWorkspaceEvent(MONITORING_EVENT, {
await auditService.insertWorkspaceEvent(MONITORING_EVENT, {
eventName: 'workspace.impersonation.token_exchange_success',
message: `Impersonation token exchanged for ${targetUserEmail} by userId ${impersonatorUserWorkspace.user.id}`,
});
@@ -28,7 +28,7 @@ export class WorkspaceAuthContextMiddleware implements NestMiddleware {
applyWorkspaceSentryContext(authContext);
withWorkspaceAuthContext(authContext, () => {
void withWorkspaceAuthContext(authContext, () => {
next();
});
}
@@ -432,7 +432,7 @@ export class AuthService {
userId: _impersonatorUserId,
});
analytics.insertWorkspaceEvent('Monitoring', {
await analytics.insertWorkspaceEvent('Monitoring', {
eventName: 'workspace.impersonation.attempted',
message: `correlationId=${correlationId}; impersonatorUserWorkspaceId=${impersonatorUserWorkspaceId}; targetUserWorkspaceId=${impersonatedUserWorkspaceId}; workspaceId=${workspaceId}`,
});
@@ -458,7 +458,7 @@ export class AuthService {
true,
);
analytics.insertWorkspaceEvent('Monitoring', {
await analytics.insertWorkspaceEvent('Monitoring', {
eventName: 'workspace.impersonation.issued',
message: `correlationId=${correlationId}; impersonatorUserWorkspaceId=${impersonatorUserWorkspaceId}; targetUserWorkspaceId=${impersonatedUserWorkspaceId}; workspaceId=${workspaceId}`,
});
@@ -384,7 +384,7 @@ export class SignInUpService {
undefined,
);
this.metricsService.incrementCounter({
void this.metricsService.incrementCounter({
key: MetricsKeys.SignUpSuccess,
shouldStoreInCache: false,
});
@@ -597,7 +597,7 @@ export class SignInUpService {
await queryRunner.commitTransaction();
this.auditService
void this.auditService
.createContext({ workspaceId })
.insertWorkspaceEvent(WORKSPACE_CREATED_EVENT, {});
@@ -228,7 +228,7 @@ export class BillingWebhookInvoiceService {
if (isDefined(billingCustomer)) {
await this.delaySuspendedWorkspaceCleanup(billingCustomer);
this.auditService
await this.auditService
.createContext({ workspaceId: billingCustomer.workspaceId })
.insertWorkspaceEvent(PAYMENT_RECEIVED_EVENT, {
amountPaid: data.object.amount_paid,
@@ -46,21 +46,23 @@ export class BillingSyncPlansDataCommand extends MigrationCommandRunner {
meters: Stripe.Billing.Meter[],
options: MigrationCommandOptions,
) {
meters.map(async (meter) => {
try {
if (!options.dryRun) {
await this.billingMeterRepository.upsert(
transformStripeMeterToDatabaseMeter(meter),
{
conflictPaths: ['stripeMeterId'],
},
);
await Promise.all(
meters.map(async (meter) => {
try {
if (!options.dryRun) {
await this.billingMeterRepository.upsert(
transformStripeMeterToDatabaseMeter(meter),
{
conflictPaths: ['stripeMeterId'],
},
);
}
this.logger.log(`Upserted meter: ${meter.id}`);
} catch (error) {
this.logger.error(`Error upserting meter ${meter.id}: ${error}`);
}
this.logger.log(`Upserted meter: ${meter.id}`);
} catch (error) {
this.logger.error(`Error upserting meter ${meter.id}: ${error}`);
}
});
}),
);
}
private async upsertProductRepositoryData(
@@ -97,13 +97,13 @@ export class CacheStorageService {
return;
}
this.get(key).then((res: string[]) => {
if (res) {
this.set(key, [...res, ...value], ttl);
} else {
this.set(key, value, ttl);
}
});
const res = await this.get<string[]>(key);
if (res) {
await this.set(key, [...res, ...value], ttl);
} else {
await this.set(key, value, ttl);
}
}
async setRemove(key: string, values: string[]): Promise<number> {
@@ -146,15 +146,15 @@ export class CacheStorageService {
);
}
return this.get(key).then((res: string[]) => {
if (res) {
this.set(key, res.slice(0, -size));
const res = await this.get<string[]>(key);
return res.slice(-size);
}
if (res) {
await this.set(key, res.slice(0, -size));
return [];
});
return res.slice(-size);
}
return [];
}
async getSetLength(key: string) {
@@ -164,9 +164,9 @@ export class CacheStorageService {
);
}
return this.get(key).then((res: string[]) => {
return res.length;
});
const res = await this.get<string[]>(key);
return res?.length ?? 0;
}
async setMembers(key: string): Promise<string[]> {
@@ -114,7 +114,7 @@ export class CustomDomainManagerService {
workspaceId: workspace.id,
});
analytics.insertWorkspaceEvent(
void analytics.insertWorkspaceEvent(
workspace.isCustomDomainEnabled
? CUSTOM_DOMAIN_ACTIVATED_EVENT
: CUSTOM_DOMAIN_DEACTIVATED_EVENT,
@@ -116,7 +116,7 @@ export const useGraphQLErrorHandlerHook = <
setResult,
}) => {
if (!result.errors || result.errors.length === 0) {
options.metricsService.incrementCounter({
void options.metricsService.incrementCounter({
key: MetricsKeys.GraphqlOperation200,
});
@@ -178,9 +178,11 @@ export const useGraphQLErrorHandlerHook = <
}
if (metricKey) {
options.metricsService.incrementCounter({ key: metricKey });
void options.metricsService.incrementCounter({
key: metricKey,
});
} else {
options.metricsService.incrementCounter({
void options.metricsService.incrementCounter({
key: MetricsKeys.GraphqlOperationUnknown,
});
}
@@ -280,7 +282,7 @@ export const useGraphQLErrorHandlerHook = <
isDefined(currentMetadataVersion) &&
requestMetadataVersion !== `${currentMetadataVersion}`
) {
options.metricsService.incrementCounter({
void options.metricsService.incrementCounter({
key: MetricsKeys.SchemaVersionMismatch,
});
@@ -310,7 +312,7 @@ export const useGraphQLErrorHandlerHook = <
isDefined(backendMajor) &&
frontEndMajor < backendMajor
) {
options.metricsService.incrementCounter({
void options.metricsService.incrementCounter({
key: MetricsKeys.AppVersionMismatch,
});
throw new GraphQLError(APP_VERSION_MISMATCH_ERROR, {
@@ -113,6 +113,6 @@ export class I18nService implements OnModuleInit {
}
async onModuleInit() {
this.loadTranslations();
await this.loadTranslations();
}
}
@@ -159,13 +159,13 @@ export class ImpersonationService {
userId: impersonatorUserWorkspace.userId,
});
auditService.insertWorkspaceEvent(MONITORING_EVENT, {
await auditService.insertWorkspaceEvent(MONITORING_EVENT, {
eventName: `${impersonationLevel}.impersonation.attempt`,
message: `Impersonation attempt: targetUserId=${toImpersonateUserWorkspace.user.id}, workspaceId=${toImpersonateUserWorkspace.workspace.id}, impersonatorUserId=${impersonatorUserWorkspace.user.id}`,
});
try {
auditService.insertWorkspaceEvent(MONITORING_EVENT, {
await auditService.insertWorkspaceEvent(MONITORING_EVENT, {
eventName: `${impersonationLevel}.impersonation.login_token_attempt`,
message: `Impersonation token generation attempt for user ${toImpersonateUserWorkspace.user.id}`,
});
@@ -179,7 +179,7 @@ export class ImpersonationService {
},
);
auditService.insertWorkspaceEvent(MONITORING_EVENT, {
await auditService.insertWorkspaceEvent(MONITORING_EVENT, {
eventName: `${impersonationLevel}.impersonation.login_token_generated`,
message: `Impersonation token generated successfully for user ${toImpersonateUserWorkspace.user.id}`,
});
@@ -194,7 +194,7 @@ export class ImpersonationService {
loginToken,
};
} catch {
auditService.insertWorkspaceEvent(MONITORING_EVENT, {
await auditService.insertWorkspaceEvent(MONITORING_EVENT, {
eventName: `${impersonationLevel}.impersonation.login_token_failed`,
message: `Impersonation token generation failed for targetUserId=${toImpersonateUserWorkspace.user.id}`,
});
@@ -72,7 +72,7 @@ const createZip = async (buildDir, zipPath) => {
const p = pipeline(archive, output);
archive.directory(buildDir, false);
archive.finalize();
void archive.finalize();
return p;
};
@@ -807,7 +807,7 @@ export class LambdaDriver implements LogicFunctionDriver {
await new Promise<void>((resolve, reject) => {
archive.on('end', resolve);
archive.on('error', reject);
archive.finalize();
void archive.finalize();
});
return Buffer.concat(chunks);
@@ -15,7 +15,7 @@ export const createZipFile = async (
const p = pipeline(archive, output);
archive.directory(sourceDir, false);
archive.finalize();
void archive.finalize();
return p;
};
@@ -332,7 +332,9 @@ export class LogicFunctionExecutorService {
executionId,
}));
this.applicationLogsService.writeLogs(logEntries);
void this.applicationLogsService.writeLogs(logEntries).catch((error) => {
this.logger.error('Failed to persist application logs', error);
});
await this.subscriptionService.publish({
channel: SubscriptionChannel.LOGIC_FUNCTION_LOGS_CHANNEL,
@@ -349,7 +351,7 @@ export class LogicFunctionExecutorService {
},
});
this.auditService
void this.auditService
.createContext({
workspaceId,
})
@@ -92,7 +92,7 @@ export class BullMQDriver
]);
}
async work<T>(
work<T>(
queueName: MessageQueue,
handler: (job: MessageQueueJob<T>) => Promise<void>,
options?: MessageQueueWorkerOptions,
@@ -136,7 +136,7 @@ export class BullMQDriver
);
this.workerMap[queueName].on('completed', (job) => {
this.metricsService.incrementCounter({
void this.metricsService.incrementCounter({
key: MetricsKeys.JobCompleted,
attributes: { queue: queueName, job_name: job?.name ?? '' },
shouldStoreInCache: false,
@@ -148,7 +148,7 @@ export class BullMQDriver
return;
}
this.metricsService.incrementCounter({
void this.metricsService.incrementCounter({
key: MetricsKeys.JobFailed,
attributes: {
queue: queueName,
@@ -14,13 +14,11 @@ export interface MessageQueueDriver {
data: T,
options?: QueueJobOptions,
): Promise<void>;
// @ts-expect-error legacy noImplicitAny
work<T extends MessageQueueJobData>(
queueName: MessageQueue,
handler: ({ data, id }: { data: T; id: string }) => Promise<void> | void,
options?: MessageQueueWorkerOptions,
);
// @ts-expect-error legacy noImplicitAny
): void;
addCron<T extends MessageQueueJobData | undefined>({
queueName,
jobName,
@@ -33,8 +31,7 @@ export interface MessageQueueDriver {
data: T;
options: QueueCronJobOptions;
jobId?: string;
});
// @ts-expect-error legacy noImplicitAny
}): Promise<void>;
removeCron({
queueName,
jobName,
@@ -43,6 +40,6 @@ export interface MessageQueueDriver {
queueName: MessageQueue;
jobName: string;
jobId?: string;
});
}): Promise<void>;
register?(queueName: MessageQueue): void;
}
@@ -8,6 +8,7 @@ import {
import { type MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
// Synchronous driver for tests and local dev
export class SyncDriver implements MessageQueueDriver {
private readonly logger = new Logger(SyncDriver.name);
private workersMap: {
@@ -50,7 +51,7 @@ export class SyncDriver implements MessageQueueDriver {
work<T extends MessageQueueJobData>(
queueName: MessageQueue,
handler: (job: MessageQueueJob<T>) => Promise<void> | void,
) {
): void {
this.logger.log(`Registering handler for queue: ${queueName}`);
this.workersMap[queueName] = handler;
}
@@ -132,7 +132,7 @@ export class MessageQueueExplorer implements OnModuleInit {
}
}
private async handleProcessorGroupCollection(
private handleProcessorGroupCollection(
processorGroupCollection: ProcessorGroup[],
queue: MessageQueueService,
options?: MessageQueueWorkerOptions,
@@ -72,7 +72,7 @@ export class MessageQueueService {
work<T extends MessageQueueJobData>(
handler: (job: MessageQueueJob<T>) => Promise<void> | void,
options?: MessageQueueWorkerOptions,
) {
return this.driver.work(this.queueName, handler, options);
): void {
this.driver.work(this.queueName, handler, options);
}
}
@@ -98,7 +98,11 @@ export class MetricsService {
counter.add(1, attributes);
if (shouldStoreInCache && eventId) {
await this.metricsCacheService.updateCounter(key, [eventId]);
try {
await this.metricsCacheService.updateCounter(key, [eventId]);
} catch (error) {
this.logger.error(`Failed to update metrics cache for ${key}`, error);
}
}
if (isDefined(debugLog)) {