diff --git a/packages/cli/src/modules/insights/__tests__/insights-collection.service.integration.test.ts b/packages/cli/src/modules/insights/__tests__/insights-collection.service.integration.test.ts index d8f0806baa1..37dd014c92d 100644 --- a/packages/cli/src/modules/insights/__tests__/insights-collection.service.integration.test.ts +++ b/packages/cli/src/modules/insights/__tests__/insights-collection.service.integration.test.ts @@ -17,6 +17,7 @@ import { type IRun, type WorkflowExecuteMode, } from 'n8n-workflow'; +import assert from 'node:assert'; import type { TypeUnit } from '@/modules/insights/database/entities/insights-shared'; import { InsightsMetadataRepository } from '@/modules/insights/database/repositories/insights-metadata.repository'; @@ -95,9 +96,7 @@ describe('workflowExecuteAfterHandler', () => { // ASSERT const metadata = await insightsMetadataRepository.findOneBy({ workflowId: workflow.id }); - if (!metadata) { - return fail('expected metadata to exist'); - } + assert(metadata, 'Expected metadata to exist'); expect(metadata).toMatchObject({ workflowId: workflow.id, @@ -218,9 +217,7 @@ describe('workflowExecuteAfterHandler', () => { // ASSERT const metadata = await insightsMetadataRepository.findOneBy({ workflowId: workflow.id }); - if (!metadata) { - return fail('expected metadata to exist'); - } + assert(metadata, 'Expected metadata to exist'); expect(metadata).toMatchObject({ workflowId: workflow.id, @@ -629,6 +626,76 @@ describe('workflowExecuteAfterHandler - flushEvents', () => { } }); + test('flushEvents rounds fractional time_saved_min for PostgreSQL BIGINT on insights_raw.value', async () => { + repoMocks.insertInsightsRaw.mockClear(); + workflow.settings = { + timeSavedMode: 'dynamic', + }; + const ctx = mock({ + workflow, + runData: mock({ + mode: 'webhook', + status: 'success', + startedAt: startedAt.toJSDate(), + stoppedAt: stoppedAt.toJSDate(), + data: { + resultData: { + runData: { + timeSavedNode: [{ metadata: { timeSaved: { minutes: 5.4 } } }], + }, + }, + }, + }), + }); + + await insightsCollectionService.handleWorkflowExecuteAfter(ctx); + await insightsCollectionService.flushEvents(); + + expect(repoMocks.insertInsightsRaw).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ type: 'time_saved_min', value: 5 })]), + ); + }); + + test.each<{ label: string; timeSavedPerExecution: number }>([ + { label: 'NaN', timeSavedPerExecution: Number.NaN }, + { label: 'Infinity', timeSavedPerExecution: Number.POSITIVE_INFINITY }, + ])( + 'flushEvents normalizes time_saved_min to 0 when timeSavedPerExecution is $label (PostgreSQL BIGINT)', + async ({ timeSavedPerExecution }) => { + repoMocks.insertInsightsRaw.mockClear(); + workflow.settings = { + timeSavedMode: 'fixed', + timeSavedPerExecution, + }; + const ctx = mock({ workflow, runData }); + + await insightsCollectionService.handleWorkflowExecuteAfter(ctx); + await insightsCollectionService.flushEvents(); + + expect(repoMocks.insertInsightsRaw).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ type: 'time_saved_min', value: 0 })]), + ); + }, + ); + + test('flushEvents normalizes runtime_ms to 0 when runtime is NaN (PostgreSQL BIGINT)', async () => { + repoMocks.insertInsightsRaw.mockClear(); + const badRuntimeRunData = mock({ + mode: 'trigger', + status: 'success', + startedAt: new Date(Number.NaN), + stoppedAt: stoppedAt.toJSDate(), + }); + const ctx = mock({ workflow, runData: badRuntimeRunData }); + + await insightsCollectionService.handleWorkflowExecuteAfter(ctx); + await insightsCollectionService.flushEvents(); + + expect(repoMocks.insertInsightsRaw).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ type: 'runtime_ms', value: 0 })]), + ); + }); + test('waits for ongoing flush during shutdown', async () => { // ARRANGE const config = Container.get(InsightsConfig); diff --git a/packages/cli/src/modules/insights/insights-collection.service.ts b/packages/cli/src/modules/insights/insights-collection.service.ts index 475e9e45547..71a585e254e 100644 --- a/packages/cli/src/modules/insights/insights-collection.service.ts +++ b/packages/cli/src/modules/insights/insights-collection.service.ts @@ -55,6 +55,17 @@ const MIN_RUNTIME = 0; // PostgreSQL INTEGER max (signed 32-bit) const MAX_RUNTIME = 2 ** 31 - 1; +/** + * `insights_raw.value` is stored as BIGINT in PostgreSQL. Non-integer JavaScript + * numbers are serialized with a fractional part and rejected by the driver + */ +function integerValueForInsightsRaw(value: number): number { + if (!Number.isFinite(value)) { + return 0; + } + return Math.round(value); +} + type BufferedInsight = Pick & { workflowId: string; workflowName: string; @@ -256,7 +267,7 @@ export class InsightsCollectionService { } insight.metaId = metadata.metaId; insight.type = event.type; - insight.value = event.value; + insight.value = integerValueForInsightsRaw(event.value); insight.timestamp = event.timestamp; events.push(insight); diff --git a/packages/frontend/editor-ui/src/app/components/WorkflowSettings.vue b/packages/frontend/editor-ui/src/app/components/WorkflowSettings.vue index 0af74730684..1f303046e0a 100644 --- a/packages/frontend/editor-ui/src/app/components/WorkflowSettings.vue +++ b/packages/frontend/editor-ui/src/app/components/WorkflowSettings.vue @@ -1419,6 +1419,7 @@ onBeforeUnmount(() => { :disabled="readOnlyEnv || !workflowPermissions.update" data-test-id="workflow-settings-time-saved-per-execution" :min="0" + :precision="0" @update:model-value="updateTimeSavedPerExecution" /> {{ i18n.baseText('workflowSettings.timeSavedPerExecution.hint') }} diff --git a/packages/frontend/editor-ui/src/features/ndv/parameters/components/ParameterInput.vue b/packages/frontend/editor-ui/src/features/ndv/parameters/components/ParameterInput.vue index f2f6e3e2c72..e6ceb78c812 100644 --- a/packages/frontend/editor-ui/src/features/ndv/parameters/components/ParameterInput.vue +++ b/packages/frontend/editor-ui/src/features/ndv/parameters/components/ParameterInput.vue @@ -1,6 +1,6 @@