diff --git a/static/app/utils/analytics/logsAnalyticsEvent.tsx b/static/app/utils/analytics/logsAnalyticsEvent.tsx index 4b6ddce0dd8d36..41f387231f70aa 100644 --- a/static/app/utils/analytics/logsAnalyticsEvent.tsx +++ b/static/app/utils/analytics/logsAnalyticsEvent.tsx @@ -12,9 +12,10 @@ export type LogsAnalyticsEventParameters = { page_source: LogsAnalyticsPageSource; }; 'logs.auto_refresh.toggled': { - enabled: boolean; + fromPaused: boolean; organization: Organization; page_source: LogsAnalyticsPageSource; + toggleState: 'enabled' | 'disabled'; }; 'logs.doc_link.clicked': { organization: Organization; diff --git a/static/app/views/explore/contexts/logs/logsAutoRefreshContext.tsx b/static/app/views/explore/contexts/logs/logsAutoRefreshContext.tsx index f548e5f4bac424..2a7f450af9cbd8 100644 --- a/static/app/views/explore/contexts/logs/logsAutoRefreshContext.tsx +++ b/static/app/views/explore/contexts/logs/logsAutoRefreshContext.tsx @@ -11,7 +11,7 @@ import {useLogsQueryKeyWithInfinite} from 'sentry/views/explore/logs/useLogsQuer export const LOGS_AUTO_REFRESH_KEY = 'live'; export const LOGS_REFRESH_INTERVAL_KEY = 'refreshEvery'; const LOGS_REFRESH_INTERVAL_DEFAULT = 5000; -const MAX_AUTO_REFRESH_PAUSED_TIME_MS = 60 * 1000; // 60 seconds +const MAX_AUTO_REFRESH_PAUSED_TIME_MS = 60 * 1000; // 10 seconds export const ABSOLUTE_MAX_AUTO_REFRESH_TIME_MS = 10 * 60 * 1000; // 10 minutes export const CONSECUTIVE_PAGES_WITH_MORE_DATA = 5; @@ -102,6 +102,11 @@ export function useLogsAutoRefreshEnabled() { return isTableFrozen ? false : autoRefresh === 'enabled'; } +export function useLogsAutoRefreshContinued() { + const {autoRefresh, pausedAt} = useLogsAutoRefresh(); + return autoRefresh === 'enabled' && pausedAtAllowedToContinue(pausedAt); +} + export function useAutorefreshEnabledOrWithinPauseWindow() { const {autoRefresh, pausedAt} = useLogsAutoRefresh(); return ( @@ -113,11 +118,14 @@ export function useAutorefreshEnabledOrWithinPauseWindow() { function withinPauseWindow(autoRefresh: AutoRefreshState, pausedAt: number | undefined) { return ( (autoRefresh === 'paused' || autoRefresh === 'enabled') && - pausedAt && - Date.now() - pausedAt < MAX_AUTO_REFRESH_PAUSED_TIME_MS + pausedAtAllowedToContinue(pausedAt) ); } +function pausedAtAllowedToContinue(pausedAt: number | undefined) { + return pausedAt && Date.now() - pausedAt < MAX_AUTO_REFRESH_PAUSED_TIME_MS; +} + export function useSetLogsAutoRefresh() { const location = useLocation(); const navigate = useNavigate(); @@ -139,6 +147,7 @@ export function useSetLogsAutoRefresh() { if (autoRefresh === 'paused') { setPausedAt(newPausedAt); } else if (autoRefresh !== 'enabled') { + // Any error state, or disabled state, should reset the pause state. setPausedAt(undefined); } diff --git a/static/app/views/explore/logs/content.spec.tsx b/static/app/views/explore/logs/content.spec.tsx index 3de832c7fb4301..0006f51ba2cf1a 100644 --- a/static/app/views/explore/logs/content.spec.tsx +++ b/static/app/views/explore/logs/content.spec.tsx @@ -1,83 +1,27 @@ -import {LogFixture, LogFixtureMeta} from 'sentry-fixture/log'; +import {createLogFixtures, initializeLogsTest} from 'sentry-fixture/log'; -import {initializeOrg} from 'sentry-test/initializeOrg'; import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; -import PageFiltersStore from 'sentry/stores/pageFiltersStore'; -import ProjectsStore from 'sentry/stores/projectsStore'; import {LOGS_AUTO_REFRESH_KEY} from 'sentry/views/explore/contexts/logs/logsAutoRefreshContext'; -import type {TraceItemResponseAttribute} from 'sentry/views/explore/hooks/useTraceItemDetails'; -import {OurLogKnownFieldKey} from 'sentry/views/explore/logs/types'; import LogsPage from './content'; -const BASE_FEATURES = ['ourlogs-enabled', 'ourlogs-visualize-sidebar']; - describe('LogsPage', function () { - const {organization, project} = initializeOrg({ - organization: { - features: BASE_FEATURES, - }, - }); + const {organization, project, setupPageFilters, setupEventsMock} = initializeLogsTest(); - ProjectsStore.loadInitialData([project]); - PageFiltersStore.init(); - PageFiltersStore.onInitializeUrlState( - { - projects: [project].map(p => parseInt(p.id, 10)), - environments: [], - datetime: {period: '12h', start: null, end: null, utc: null}, - }, - new Set() - ); + setupPageFilters(); let eventTableMock: jest.Mock; let eventStatsMock: jest.Mock; + + // Standard log fixtures for consistent testing + const testDate = new Date('2024-01-15T10:00:00.000Z'); + const {baseFixtures} = createLogFixtures(organization, project, testDate); + beforeEach(function () { - organization.features = BASE_FEATURES; MockApiClient.clearMockResponses(); - eventTableMock = MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/events/`, - method: 'GET', - body: { - data: [ - { - 'sentry.item_id': '019621262d117e03bce898cb8f4f6ff7', - 'project.id': 1, - trace: '17cc0bae407042eaa4bf6d798c37d026', - severity_number: 9, - severity_text: 'info', - timestamp: '2025-04-10T19:21:12+00:00', - message: 'some log message1', - 'tags[sentry.timestamp_precise,number]': 1.7443128722090732e18, - }, - { - 'sentry.item_id': '0196212624a17144aa392d01420256a2', - 'project.id': 1, - trace: 'c331c2df93d846f5a2134203416d40bb', - severity_number: 9, - severity_text: 'info', - timestamp: '2025-04-10T19:21:10+00:00', - message: 'some log message2', - 'tags[sentry.timestamp_precise,number]': 1.744312870049196e18, - }, - ], - meta: { - fields: { - 'sentry.item_id': 'string', - 'project.id': 'string', - trace: 'string', - severity_number: 'integer', - severity_text: 'string', - timestamp: 'string', - message: 'string', - 'tags[sentry.timestamp_precise,number]': 'number', - }, - units: {}, - }, - }, - }); + eventTableMock = setupEventsMock(baseFixtures.slice(0, 2)); eventStatsMock = MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/events-stats/`, @@ -127,10 +71,12 @@ describe('LogsPage', function () { }); const table = screen.getByTestId('logs-table'); - expect(await screen.findByText('some log message1')).toBeInTheDocument(); + expect( + await screen.findByText('Error occurred in authentication service') + ).toBeInTheDocument(); expect(table).not.toHaveTextContent(/auto refresh/i); - expect(table).toHaveTextContent(/some log message1/); - expect(table).toHaveTextContent(/some log message2/); + expect(table).toHaveTextContent(/Error occurred in authentication service/); + expect(table).toHaveTextContent(/User login successful/); }); it('should call aggregates APIs as expected', async function () { @@ -190,193 +136,124 @@ describe('LogsPage', function () { }); }); - it('enables autorefresh when Switch is clicked', async function () { - const {organization: newOrganization} = initializeOrg({ - organization: { - features: [...BASE_FEATURES, 'ourlogs-infinite-scroll', 'ourlogs-live-refresh'], - }, + describe('autorefresh flag enabled', () => { + const { + organization: autorefreshOrganization, + setupEventsMock: _setupEventsMock, + setupTraceItemsMock: _setupTraceItemsMock, + generateRouterConfig, + routerConfig: initialRouterConfig, + } = initializeLogsTest({ + refreshInterval: '10000', // 10 seconds, should not first events multiple times. + liveRefresh: true, }); - MockApiClient.addMockResponse({ - url: `/organizations/${newOrganization.slug}/events/`, - method: 'GET', - body: { - data: [ - { - 'sentry.item_id': '1', - 'project.id': 1, - trace: 'trace1', - severity_number: 9, - severity_text: 'info', - timestamp: '2025-04-10T19:21:12+00:00', - message: 'some log message', - 'tags[sentry.timestamp_precise,number]': 100, - }, - ], - meta: {fields: {}, units: {}}, - }, - }); + const {baseFixtures: autorefreshBaseFixtures} = createLogFixtures( + autorefreshOrganization, + project, + testDate, + { + intervalMs: 24 * 60 * 60 * 1000, // 24 hours + } + ); - render(, { - organization: newOrganization, - initialRouterConfig: { - location: `/organizations/${newOrganization.slug}/explore/logs/`, - }, + beforeEach(() => { + eventTableMock.mockClear(); + eventTableMock = _setupEventsMock(autorefreshBaseFixtures.slice(0, 5)); }); - await waitFor(() => { - expect(screen.getByTestId('logs-table')).toBeInTheDocument(); - }); + it('enables autorefresh when Switch is clicked', async function () { + render(, { + organization: autorefreshOrganization, + initialRouterConfig, + }); - const switchInput = screen.getByRole('checkbox', {name: /auto-refresh/i}); - expect(switchInput).not.toBeChecked(); - expect(switchInput).toBeEnabled(); + await waitFor(() => { + expect(screen.getByTestId('logs-table')).toBeInTheDocument(); + }); - await userEvent.click(switchInput); + const switchInput = screen.getByRole('checkbox', {name: /auto-refresh/i}); + expect(switchInput).not.toBeChecked(); + expect(switchInput).toBeEnabled(); - await waitFor( - () => { - expect(switchInput).toBeChecked(); - }, - {timeout: 5000} - ); - }); + await userEvent.click(switchInput); - it('pauses auto-refresh when enabled switch is clicked', async function () { - const {organization: newOrganization} = initializeOrg({ - organization: { - features: [...BASE_FEATURES, 'ourlogs-infinite-scroll', 'ourlogs-live-refresh'], - }, + await waitFor(() => { + expect(switchInput).toBeChecked(); + }); }); - MockApiClient.addMockResponse({ - url: `/organizations/${newOrganization.slug}/events/`, - method: 'GET', - body: { - data: [ - { - 'sentry.item_id': '1', - 'project.id': 1, - trace: 'trace1', - severity_number: 9, - severity_text: 'info', - timestamp: '2025-04-10T19:21:12+00:00', - message: 'some log message', - 'tags[sentry.timestamp_precise,number]': 100, - }, - ], - meta: {fields: {}, units: {}}, - }, - }); + it('pauses auto-refresh when enabled switch is clicked', async function () { + const {router} = render(, { + organization: autorefreshOrganization, + initialRouterConfig: generateRouterConfig({ + [LOGS_AUTO_REFRESH_KEY]: 'enabled', + }), + }); + expect(router.location.query[LOGS_AUTO_REFRESH_KEY]).toBe('enabled'); - const {router} = render(, { - organization: newOrganization, - initialRouterConfig: { - location: { - pathname: `/organizations/${newOrganization.slug}/explore/logs/`, - query: { - [LOGS_AUTO_REFRESH_KEY]: 'enabled', - }, - }, - }, - }); - expect(router.location.query[LOGS_AUTO_REFRESH_KEY]).toBe('enabled'); + await waitFor(() => { + expect(screen.getByTestId('logs-table')).toBeInTheDocument(); + }); - await waitFor(() => { - expect(screen.getByTestId('logs-table')).toBeInTheDocument(); - }); + const switchInput = screen.getByRole('checkbox', {name: /auto-refresh/i}); + expect(switchInput).toBeChecked(); - const switchInput = screen.getByRole('checkbox', {name: /auto-refresh/i}); - expect(switchInput).toBeChecked(); + await userEvent.click(switchInput); - await userEvent.click(switchInput); - - await waitFor(() => { - expect(router.location.query[LOGS_AUTO_REFRESH_KEY]).toBe('paused'); + await waitFor(() => { + expect(router.location.query[LOGS_AUTO_REFRESH_KEY]).toBe('paused'); + }); + expect(switchInput).not.toBeChecked(); }); - expect(switchInput).not.toBeChecked(); - }); - it('pauses auto-refresh when row is clicked', async function () { - const {organization: newOrganization, project: newProject} = initializeOrg({ - organization: { - features: [...BASE_FEATURES, 'ourlogs-infinite-scroll', 'ourlogs-live-refresh'], - }, - }); + it('pauses auto-refresh when row is clicked', async function () { + const rowDetailsMock = _setupTraceItemsMock(autorefreshBaseFixtures.slice(0, 1))[0]; + const {router} = render(, { + organization: autorefreshOrganization, + initialRouterConfig: generateRouterConfig({ + [LOGS_AUTO_REFRESH_KEY]: 'enabled', + }), + }); - ProjectsStore.loadInitialData([newProject]); - - const rowData = LogFixture({ - [OurLogKnownFieldKey.ID]: '1', - [OurLogKnownFieldKey.PROJECT_ID]: newProject.id, - [OurLogKnownFieldKey.ORGANIZATION_ID]: Number(newOrganization.id), - [OurLogKnownFieldKey.TRACE_ID]: '7b91699f', - [OurLogKnownFieldKey.MESSAGE]: 'some log message', - [OurLogKnownFieldKey.TIMESTAMP]: '2025-04-10T19:21:12+00:00', - [OurLogKnownFieldKey.TIMESTAMP_PRECISE]: 100, - [OurLogKnownFieldKey.SEVERITY_NUMBER]: 9, - [OurLogKnownFieldKey.SEVERITY]: 'info', - }); + expect(router.location.query[LOGS_AUTO_REFRESH_KEY]).toBe('enabled'); - const rowDetails = Object.entries(rowData).map( - ([k, v]) => - ({ - name: k, - value: v, - type: typeof v === 'string' ? 'str' : 'float', - }) as TraceItemResponseAttribute - ); + await waitFor(() => { + expect(screen.getByTestId('logs-table')).toBeInTheDocument(); + }); - MockApiClient.addMockResponse({ - url: `/organizations/${newOrganization.slug}/events/`, - method: 'GET', - body: { - data: [rowData], - meta: {fields: {}, units: {}}, - }, - }); + expect(screen.getAllByTestId('log-table-row')).toHaveLength(5); - const rowDetailsMock = MockApiClient.addMockResponse({ - url: `/projects/${newOrganization.slug}/${newProject.slug}/trace-items/${rowData[OurLogKnownFieldKey.ID]}/`, - method: 'GET', - body: { - itemId: rowData[OurLogKnownFieldKey.ID], - links: null, - meta: LogFixtureMeta(rowData), - timestamp: rowData[OurLogKnownFieldKey.TIMESTAMP], - attributes: rowDetails, - }, - }); + expect(rowDetailsMock).not.toHaveBeenCalled(); - const {router} = render(, { - organization: newOrganization, - initialRouterConfig: { - location: { - pathname: `/organizations/${newOrganization.slug}/explore/logs/`, - query: { - [LOGS_AUTO_REFRESH_KEY]: 'enabled', - }, - }, - }, - }); + const row = screen.getByText('Error occurred in authentication service'); + await userEvent.click(row.parentElement!.parentElement!); // Avoid clicking the cells which can have their own click handlers. - expect(router.location.query[LOGS_AUTO_REFRESH_KEY]).toBe('enabled'); + await waitFor(() => { + expect(rowDetailsMock).toHaveBeenCalled(); + }); - await waitFor(() => { - expect(screen.getByTestId('logs-table')).toBeInTheDocument(); - }); + await waitFor(() => { + expect(router.location.query[LOGS_AUTO_REFRESH_KEY]).toBe('paused'); + }); - expect(rowDetailsMock).not.toHaveBeenCalled(); + const switchInput = screen.getByRole('checkbox', {name: /auto-refresh/i}); + expect(switchInput).not.toBeChecked(); + expect(switchInput).toBeEnabled(); - const row = screen.getByText('some log message'); - await userEvent.click(row); + expect(eventTableMock).toHaveBeenCalledTimes(1); + eventTableMock.mockClear(); + eventTableMock = _setupEventsMock(autorefreshBaseFixtures.slice(0, 5)); - await waitFor(() => { - expect(rowDetailsMock).toHaveBeenCalled(); - }); + await userEvent.click(switchInput); - await waitFor(() => { - expect(router.location.query[LOGS_AUTO_REFRESH_KEY]).toBe('paused'); + await waitFor(() => { + expect(router.location.query[LOGS_AUTO_REFRESH_KEY]).toBe('enabled'); + }); + + expect(eventTableMock).toHaveBeenCalledTimes(1); + + expect(switchInput).toBeChecked(); }); }); }); diff --git a/static/app/views/explore/logs/logsAutoRefresh.spec.tsx b/static/app/views/explore/logs/logsAutoRefresh.spec.tsx index 3733959741ca3c..b5782437c2cd23 100644 --- a/static/app/views/explore/logs/logsAutoRefresh.spec.tsx +++ b/static/app/views/explore/logs/logsAutoRefresh.spec.tsx @@ -1,17 +1,11 @@ import React from 'react'; -import type {RouterConfig} from '@react-types/shared'; -import {LogFixture} from 'sentry-fixture/log'; -import {OrganizationFixture} from 'sentry-fixture/organization'; -import {ProjectFixture} from 'sentry-fixture/project'; +import {createLogFixtures, initializeLogsTest} from 'sentry-fixture/log'; import {act, render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; import PageFiltersStore from 'sentry/stores/pageFiltersStore'; import {LogsAnalyticsPageSource} from 'sentry/utils/analytics/logsAnalyticsEvent'; -import { - LOGS_AUTO_REFRESH_KEY, - LOGS_REFRESH_INTERVAL_KEY, -} from 'sentry/views/explore/contexts/logs/logsAutoRefreshContext'; +import {LOGS_AUTO_REFRESH_KEY} from 'sentry/views/explore/contexts/logs/logsAutoRefreshContext'; import {LogsPageDataProvider} from 'sentry/views/explore/contexts/logs/logsPageData'; import { LOGS_FIELDS_KEY, @@ -19,68 +13,39 @@ import { } from 'sentry/views/explore/contexts/logs/logsPageParams'; import {LOGS_SORT_BYS_KEY} from 'sentry/views/explore/contexts/logs/sortBys'; import {AutorefreshToggle} from 'sentry/views/explore/logs/logsAutoRefresh'; -import {OurLogKnownFieldKey} from 'sentry/views/explore/logs/types'; - -const REFRESH_INTERVAL_MS = 100; describe('LogsAutoRefresh Integration Tests', () => { - const organization = OrganizationFixture({ - features: ['ourlogs-enabled', 'ourlogs-live-refresh', 'ourlogs-infinite-scroll'], - }); + const {organization, project, routerConfig, setupPageFilters, setupEventsMock} = + initializeLogsTest({ + liveRefresh: true, // Enable ourlogs-live-refresh for these tests + }); - const projects = [ProjectFixture()]; - - const initialRouterConfig = { - location: { - pathname: `/organizations/${organization.slug}/explore/logs/`, - query: { - // Toggle is disabled if sort is not a timestamp - [LOGS_SORT_BYS_KEY]: '-timestamp', - [LOGS_REFRESH_INTERVAL_KEY]: REFRESH_INTERVAL_MS, // Fast refresh for testing - }, - }, - route: '/organizations/:orgId/explore/logs/', - }; + const testDate = new Date('2024-01-15T10:00:00.000Z'); + const {baseFixtures} = createLogFixtures(organization, project, testDate); const enabledRouterConfig = { - ...initialRouterConfig, + ...routerConfig, location: { - ...initialRouterConfig.location, - query: {...initialRouterConfig.location.query, [LOGS_AUTO_REFRESH_KEY]: 'enabled'}, + ...routerConfig.location, + query: {...routerConfig.location.query, [LOGS_AUTO_REFRESH_KEY]: 'enabled'}, }, }; - const mockLogsData = [ - LogFixture({ - [OurLogKnownFieldKey.ORGANIZATION_ID]: Number(organization.id), - [OurLogKnownFieldKey.PROJECT_ID]: String(projects[0]!.id), - }), - ]; + setupPageFilters(); + + let mockApiCall: jest.Mock; beforeEach(() => { jest.clearAllMocks(); MockApiClient.clearMockResponses(); - // Init PageFiltersStore to make pageFiltersReady true (otherwise logs query won't fire) - PageFiltersStore.init(); - PageFiltersStore.onInitializeUrlState( - { - projects: [parseInt(projects[0]!.id, 10)], - environments: [], - datetime: { - period: '7d', - start: null, - end: null, - utc: null, - }, - }, - new Set() - ); + // Default API mock + mockApiCall = setupEventsMock(baseFixtures.slice(0, 1)); }); const renderWithProviders = ( children: React.ReactNode, - options: Parameters[1] & {initialRouterConfig: RouterConfig} + options: Parameters[1] ) => { const result = render( @@ -94,17 +59,9 @@ describe('LogsAutoRefresh Integration Tests', () => { return result; }; - const mockApiCall = () => - MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/events/`, - method: 'GET', - body: {data: mockLogsData}, - }); - it('renders correctly with time-based sort', async () => { - const mockApi = mockApiCall(); const {router} = renderWithProviders(, { - initialRouterConfig, + initialRouterConfig: routerConfig, organization, }); @@ -115,16 +72,15 @@ describe('LogsAutoRefresh Integration Tests', () => { expect(toggleSwitch).not.toBeChecked(); await waitFor(() => { - expect(mockApi).toHaveBeenCalledTimes(1); + expect(mockApiCall).toHaveBeenCalledTimes(1); }); expect(router.location.query[LOGS_AUTO_REFRESH_KEY]).toBeUndefined(); }); it('enables auto-refresh when toggled and updates URL', async () => { - const mockApi = mockApiCall(); const {router} = renderWithProviders(, { - initialRouterConfig, + initialRouterConfig: routerConfig, organization, }); @@ -140,12 +96,10 @@ describe('LogsAutoRefresh Integration Tests', () => { expect(toggleSwitch).toBeChecked(); }); - expect(mockApi).toHaveBeenCalled(); + expect(mockApiCall).toHaveBeenCalled(); }); it('disables auto-refresh when toggled off and sets paused state', async () => { - mockApiCall(); - const {router} = renderWithProviders(, { initialRouterConfig: enabledRouterConfig, organization, @@ -168,12 +122,10 @@ describe('LogsAutoRefresh Integration Tests', () => { }); it('disables toggle when using non-timestamp sort', async () => { - mockApiCall(); - const nonTimestampRouterConfig = { - ...initialRouterConfig, + ...routerConfig, location: { - ...initialRouterConfig.location, + ...routerConfig.location, query: { [LOGS_SORT_BYS_KEY]: 'level', // Non-timestamp sort [LOGS_FIELDS_KEY]: ['level', 'timestamp'], // Fields have to be set for sort bys to be applied @@ -199,8 +151,6 @@ describe('LogsAutoRefresh Integration Tests', () => { }); it('disables toggle when using absolute date range', async () => { - mockApiCall(); - // Update PageFiltersStore with absolute dates act(() => { PageFiltersStore.updateDateTime({ @@ -212,7 +162,7 @@ describe('LogsAutoRefresh Integration Tests', () => { }); renderWithProviders(, { - initialRouterConfig, + initialRouterConfig: routerConfig, organization, }); @@ -231,16 +181,15 @@ describe('LogsAutoRefresh Integration Tests', () => { }); it('shows error state in URL when query fails', async () => { - const mockCall = mockApiCall(); - const {router} = renderWithProviders(, { initialRouterConfig: enabledRouterConfig, organization, }); await screen.findByRole('checkbox', {name: 'Auto-refresh'}); - expect(mockCall).toHaveBeenCalledTimes(1); + expect(mockApiCall).toHaveBeenCalledTimes(1); + // Clear default mocks and add custom error mock MockApiClient.clearMockResponses(); const mockErrorCall = MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/events/`, @@ -259,8 +208,6 @@ describe('LogsAutoRefresh Integration Tests', () => { }); it('shows as checked and calls api repeatedly when auto-refresh is enabled', async () => { - const mockApi = mockApiCall(); - renderWithProviders(, { initialRouterConfig: enabledRouterConfig, organization, @@ -270,16 +217,19 @@ describe('LogsAutoRefresh Integration Tests', () => { expect(toggleSwitch).toBeChecked(); await waitFor(() => { - expect(mockApi).toHaveBeenCalledTimes(5); + expect(mockApiCall).toHaveBeenCalledTimes(5); }); }); it('disables auto-refresh after 5 consecutive requests with more data', async () => { jest.useFakeTimers(); + + // Clear default mocks and add custom mock + MockApiClient.clearMockResponses(); const mockApi = MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/events/`, method: 'GET', - body: {data: mockLogsData}, + body: {data: baseFixtures.slice(0, 1)}, headers: { Link: '; rel="next"; results="true"', }, @@ -301,10 +251,12 @@ describe('LogsAutoRefresh Integration Tests', () => { }); it('continues auto-refresh when there is no more data', async () => { + // Clear default mocks and add custom mock + MockApiClient.clearMockResponses(); const mockApi = MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/events/`, method: 'GET', - body: {data: mockLogsData}, + body: {data: baseFixtures.slice(0, 1)}, headers: { Link: '', // No Link header means no more data }, @@ -327,7 +279,8 @@ describe('LogsAutoRefresh Integration Tests', () => { }); it('does not rate limit on initial load even with more data', async () => { - // Mock API response with Link header indicating more data + // Clear default mocks and add custom mock with Link header indicating more data + MockApiClient.clearMockResponses(); const mockCall = MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/events/`, method: 'GET', @@ -335,7 +288,7 @@ describe('LogsAutoRefresh Integration Tests', () => { Link: '; rel="previous"; results="false"; cursor="0:0:1", ; rel="next"; results="true"; cursor="0:100:0"', }, body: { - data: mockLogsData, + data: baseFixtures.slice(0, 1), meta: {fields: {}}, }, }); @@ -353,7 +306,6 @@ describe('LogsAutoRefresh Integration Tests', () => { // Verify auto-refresh is still enabled (not rate limited) expect(router.location.query[LOGS_AUTO_REFRESH_KEY]).toBe('enabled'); - // Wait a bit to ensure no rate limiting occurs await waitFor(() => { expect(mockCall).toHaveBeenCalledTimes(5); }); diff --git a/static/app/views/explore/logs/logsAutoRefresh.tsx b/static/app/views/explore/logs/logsAutoRefresh.tsx index 60f2b792931c8a..55af63c42019b4 100644 --- a/static/app/views/explore/logs/logsAutoRefresh.tsx +++ b/static/app/views/explore/logs/logsAutoRefresh.tsx @@ -106,7 +106,8 @@ export function AutorefreshToggle({ const newChecked = !autorefreshEnabled; trackAnalytics('logs.auto_refresh.toggled', { - enabled: newChecked, + toggleState: newChecked ? 'enabled' : 'disabled', + fromPaused: autoRefresh === 'paused', organization, page_source: analyticsPageSource, }); diff --git a/static/app/views/explore/logs/logsTab.spec.tsx b/static/app/views/explore/logs/logsTab.spec.tsx index 05e33497780736..a8e4725eb95d08 100644 --- a/static/app/views/explore/logs/logsTab.spec.tsx +++ b/static/app/views/explore/logs/logsTab.spec.tsx @@ -1,8 +1,7 @@ -import {initializeOrg} from 'sentry-test/initializeOrg'; +import {initializeLogsTest} from 'sentry-fixture/log'; + import {render, screen} from 'sentry-test/reactTestingLibrary'; -import PageFiltersStore from 'sentry/stores/pageFiltersStore'; -import ProjectsStore from 'sentry/stores/projectsStore'; import {LogsAnalyticsPageSource} from 'sentry/utils/analytics/logsAnalyticsEvent'; import {LogsPageDataProvider} from 'sentry/views/explore/contexts/logs/logsPageData'; import { @@ -29,11 +28,7 @@ const datePageFilterProps: PickableDays = { }; describe('LogsTabContent', function () { - const {organization, project} = initializeOrg({ - organization: { - features: ['ourlogs-enabled'], - }, - }); + const {organization, project, setupPageFilters} = initializeLogsTest(); let eventTableMock: jest.Mock; let eventStatsMock: jest.Mock; @@ -63,26 +58,12 @@ describe('LogsTabContent', function () { route: '/organizations/:orgId/explore/logs/', }; + setupPageFilters(); + beforeEach(function () { MockApiClient.clearMockResponses(); - ProjectsStore.loadInitialData([project]); - - PageFiltersStore.init(); - PageFiltersStore.onInitializeUrlState( - { - projects: [parseInt(project.id, 10)], - environments: [], - datetime: { - period: '14d', - start: null, - end: null, - utc: null, - }, - }, - new Set() - ); - + // Default API mocks eventTableMock = MockApiClient.addMockResponse({ url: `/organizations/${organization.slug}/events/`, method: 'GET', diff --git a/static/app/views/explore/logs/tables/logsTableRow.spec.tsx b/static/app/views/explore/logs/tables/logsTableRow.spec.tsx index 9658aab00d5735..56fd1c5b871984 100644 --- a/static/app/views/explore/logs/tables/logsTableRow.spec.tsx +++ b/static/app/views/explore/logs/tables/logsTableRow.spec.tsx @@ -43,6 +43,7 @@ describe('logsTableRow', () => { // These are the values in the actual row - e.g., the ones loaded before you click the row const rowData = LogFixture({ + [OurLogKnownFieldKey.ID]: '1', [OurLogKnownFieldKey.PROJECT_ID]: project.id, [OurLogKnownFieldKey.ORGANIZATION_ID]: Number(organization.id), [OurLogKnownFieldKey.TRACE_ID]: '7b91699f', @@ -70,6 +71,7 @@ describe('logsTableRow', () => { ); const rowDataWithCodeFilePath = LogFixture({ + [OurLogKnownFieldKey.ID]: '2', [OurLogKnownFieldKey.PROJECT_ID]: project.id, [OurLogKnownFieldKey.ORGANIZATION_ID]: Number(organization.id), // Code file path fields @@ -147,6 +149,26 @@ describe('logsTableRow', () => { attributes: rowDetails, }, }); + + // Mock for rowDataWithCodeFilePath + MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/trace-items/${rowDataWithCodeFilePath[OurLogKnownFieldKey.ID]}/`, + method: 'GET', + body: { + itemId: rowDataWithCodeFilePath[OurLogKnownFieldKey.ID], + links: null, + meta: {}, + timestamp: rowDataWithCodeFilePath[OurLogKnownFieldKey.TIMESTAMP], + attributes: Object.entries(rowDataWithCodeFilePath).map( + ([k, v]) => + ({ + name: k, + value: v, + type: typeof v === 'string' ? 'str' : 'float', + }) as TraceItemResponseAttribute + ), + }, + }); }); it('hovering the row causes prefetching of the row details', async () => { diff --git a/static/app/views/explore/logs/useStreamingTimeseriesResult.spec.tsx b/static/app/views/explore/logs/useStreamingTimeseriesResult.spec.tsx index 84c246e719df0c..357530ce5bd89e 100644 --- a/static/app/views/explore/logs/useStreamingTimeseriesResult.spec.tsx +++ b/static/app/views/explore/logs/useStreamingTimeseriesResult.spec.tsx @@ -1,11 +1,8 @@ import {LocationFixture} from 'sentry-fixture/locationFixture'; -import {LogFixture} from 'sentry-fixture/log'; -import {OrganizationFixture} from 'sentry-fixture/organization'; -import {ProjectFixture} from 'sentry-fixture/project'; +import {initializeLogsTest, LogFixture} from 'sentry-fixture/log'; import {renderHook, waitFor} from 'sentry-test/reactTestingLibrary'; -import PageFiltersStore from 'sentry/stores/pageFiltersStore'; import type {Organization} from 'sentry/types/organization'; import {LogsAnalyticsPageSource} from 'sentry/utils/analytics/logsAnalyticsEvent'; import {useLocation} from 'sentry/utils/useLocation'; @@ -28,29 +25,19 @@ function preciseTimestampFromMillis(timestamp: number) { } describe('useStreamingTimeseriesResult', () => { - const logsOrganization = OrganizationFixture({ - features: ['ourlogs-enabled', 'ourlogs-live-refresh'], + const { + organization: logsOrganization, + project, + setupPageFilters, + } = initializeLogsTest({ + liveRefresh: true, }); - const project = ProjectFixture(); + + setupPageFilters(); beforeEach(() => { jest.resetAllMocks(); mockUseLocation.mockReturnValue(LocationFixture()); - - PageFiltersStore.init(); - PageFiltersStore.onInitializeUrlState( - { - projects: [parseInt(project.id, 10)], - environments: [], - datetime: { - period: '14d', - start: null, - end: null, - utc: null, - }, - }, - new Set() - ); }); const createWrapper = ({ @@ -212,17 +199,13 @@ describe('useStreamingTimeseriesResult', () => { }) as any; it('should return original timeseries when feature flag is enabled', () => { - const orgWithFeature = OrganizationFixture({ - features: ['ourlogs-enabled', 'ourlogs-live-refresh'], - }); - const mockTableData = createMockTableData([]); const mockTimeseriesData = getMockSingleAxisTimeseries(); const {result} = renderHook( () => useStreamingTimeseriesResult(mockTableData, mockTimeseriesData, 0n), { - wrapper: createWrapper({autoRefresh: 'enabled', organization: orgWithFeature}), + wrapper: createWrapper({autoRefresh: 'enabled', organization: logsOrganization}), } ); @@ -270,15 +253,27 @@ describe('useStreamingTimeseriesResult', () => { const mockTableData = createMockTableData([ LogFixture({ + [OurLogKnownFieldKey.ID]: '1', + [OurLogKnownFieldKey.PROJECT_ID]: project.id, + [OurLogKnownFieldKey.ORGANIZATION_ID]: Number(logsOrganization.id), [OurLogKnownFieldKey.TIMESTAMP_PRECISE]: preciseTimestampFromMillis(9000), }), LogFixture({ + [OurLogKnownFieldKey.ID]: '2', + [OurLogKnownFieldKey.PROJECT_ID]: project.id, + [OurLogKnownFieldKey.ORGANIZATION_ID]: Number(logsOrganization.id), [OurLogKnownFieldKey.TIMESTAMP_PRECISE]: preciseTimestampFromMillis(8200), }), LogFixture({ + [OurLogKnownFieldKey.ID]: '3', + [OurLogKnownFieldKey.PROJECT_ID]: project.id, + [OurLogKnownFieldKey.ORGANIZATION_ID]: Number(logsOrganization.id), [OurLogKnownFieldKey.TIMESTAMP_PRECISE]: preciseTimestampFromMillis(8100), }), LogFixture({ + [OurLogKnownFieldKey.ID]: '4', + [OurLogKnownFieldKey.PROJECT_ID]: project.id, + [OurLogKnownFieldKey.ORGANIZATION_ID]: Number(logsOrganization.id), [OurLogKnownFieldKey.TIMESTAMP_PRECISE]: preciseTimestampFromMillis(8000), }), ]); @@ -361,22 +356,37 @@ describe('useStreamingTimeseriesResult', () => { const initialTableFixtures = [ LogFixture({ + [OurLogKnownFieldKey.ID]: '5', + [OurLogKnownFieldKey.PROJECT_ID]: project.id, + [OurLogKnownFieldKey.ORGANIZATION_ID]: Number(logsOrganization.id), [OurLogKnownFieldKey.TIMESTAMP_PRECISE]: preciseTimestampFromMillis(9000), [OurLogKnownFieldKey.SEVERITY]: 'brand_new_severity', }), LogFixture({ + [OurLogKnownFieldKey.ID]: '6', + [OurLogKnownFieldKey.PROJECT_ID]: project.id, + [OurLogKnownFieldKey.ORGANIZATION_ID]: Number(logsOrganization.id), [OurLogKnownFieldKey.TIMESTAMP_PRECISE]: preciseTimestampFromMillis(8001), [OurLogKnownFieldKey.SEVERITY]: 'error', }), LogFixture({ + [OurLogKnownFieldKey.ID]: '7', + [OurLogKnownFieldKey.PROJECT_ID]: project.id, + [OurLogKnownFieldKey.ORGANIZATION_ID]: Number(logsOrganization.id), [OurLogKnownFieldKey.TIMESTAMP_PRECISE]: preciseTimestampFromMillis(8001), [OurLogKnownFieldKey.SEVERITY]: 'warn', }), LogFixture({ + [OurLogKnownFieldKey.ID]: '8', + [OurLogKnownFieldKey.PROJECT_ID]: project.id, + [OurLogKnownFieldKey.ORGANIZATION_ID]: Number(logsOrganization.id), [OurLogKnownFieldKey.TIMESTAMP_PRECISE]: preciseTimestampFromMillis(8000), [OurLogKnownFieldKey.SEVERITY]: 'warn', }), LogFixture({ + [OurLogKnownFieldKey.ID]: '9', + [OurLogKnownFieldKey.PROJECT_ID]: project.id, + [OurLogKnownFieldKey.ORGANIZATION_ID]: Number(logsOrganization.id), [OurLogKnownFieldKey.TIMESTAMP_PRECISE]: preciseTimestampFromMillis(7000), [OurLogKnownFieldKey.SEVERITY]: 'warn', }), @@ -445,6 +455,9 @@ describe('useStreamingTimeseriesResult', () => { rerender( createMockTableData([ LogFixture({ + [OurLogKnownFieldKey.ID]: '10', + [OurLogKnownFieldKey.PROJECT_ID]: project.id, + [OurLogKnownFieldKey.ORGANIZATION_ID]: Number(logsOrganization.id), [OurLogKnownFieldKey.TIMESTAMP_PRECISE]: preciseTimestampFromMillis(12000), [OurLogKnownFieldKey.SEVERITY]: 'yet_another_severity', }), @@ -546,22 +559,37 @@ describe('useStreamingTimeseriesResult', () => { const mockTableData = createMockTableData([ LogFixture({ + [OurLogKnownFieldKey.ID]: '10', + [OurLogKnownFieldKey.PROJECT_ID]: project.id, + [OurLogKnownFieldKey.ORGANIZATION_ID]: Number(logsOrganization.id), [OurLogKnownFieldKey.TIMESTAMP_PRECISE]: preciseTimestampFromMillis(9000), [OurLogKnownFieldKey.SEVERITY]: 'error', }), LogFixture({ + [OurLogKnownFieldKey.ID]: '11', + [OurLogKnownFieldKey.PROJECT_ID]: project.id, + [OurLogKnownFieldKey.ORGANIZATION_ID]: Number(logsOrganization.id), [OurLogKnownFieldKey.TIMESTAMP_PRECISE]: preciseTimestampFromMillis(8600), [OurLogKnownFieldKey.SEVERITY]: 'warn', }), LogFixture({ + [OurLogKnownFieldKey.ID]: '12', + [OurLogKnownFieldKey.PROJECT_ID]: project.id, + [OurLogKnownFieldKey.ORGANIZATION_ID]: Number(logsOrganization.id), [OurLogKnownFieldKey.TIMESTAMP_PRECISE]: preciseTimestampFromMillis(8000), [OurLogKnownFieldKey.SEVERITY]: 'warn', }), LogFixture({ + [OurLogKnownFieldKey.ID]: '13', + [OurLogKnownFieldKey.PROJECT_ID]: project.id, + [OurLogKnownFieldKey.ORGANIZATION_ID]: Number(logsOrganization.id), [OurLogKnownFieldKey.TIMESTAMP_PRECISE]: preciseTimestampFromMillis(6500), [OurLogKnownFieldKey.SEVERITY]: 'info', }), LogFixture({ + [OurLogKnownFieldKey.ID]: '14', + [OurLogKnownFieldKey.PROJECT_ID]: project.id, + [OurLogKnownFieldKey.ORGANIZATION_ID]: Number(logsOrganization.id), [OurLogKnownFieldKey.TIMESTAMP_PRECISE]: preciseTimestampFromMillis(8500), [OurLogKnownFieldKey.SEVERITY]: 'error', }), diff --git a/static/app/views/explore/logs/useVirtualStreaming.spec.tsx b/static/app/views/explore/logs/useVirtualStreaming.spec.tsx index 4709d32764efad..0feb6630d11d9b 100644 --- a/static/app/views/explore/logs/useVirtualStreaming.spec.tsx +++ b/static/app/views/explore/logs/useVirtualStreaming.spec.tsx @@ -1,12 +1,9 @@ import {LocationFixture} from 'sentry-fixture/locationFixture'; -import {LogFixture} from 'sentry-fixture/log'; -import {OrganizationFixture} from 'sentry-fixture/organization'; -import {ProjectFixture} from 'sentry-fixture/project'; +import {initializeLogsTest, LogFixture} from 'sentry-fixture/log'; import {renderHook, waitFor} from 'sentry-test/reactTestingLibrary'; import type {ApiResult} from 'sentry/api'; -import PageFiltersStore from 'sentry/stores/pageFiltersStore'; import {LogsAnalyticsPageSource} from 'sentry/utils/analytics/logsAnalyticsEvent'; import type {InfiniteData} from 'sentry/utils/queryClient'; import {useLocation} from 'sentry/utils/useLocation'; @@ -30,28 +27,15 @@ const mockUseLocation = jest.mocked(useLocation); describe('useVirtualStreaming', () => { let requestAnimationFrameSpy: jest.SpyInstance; let cancelAnimationFrameSpy: jest.SpyInstance; - const organization = OrganizationFixture(); - const project = ProjectFixture(); + + const {organization, project, setupPageFilters} = initializeLogsTest(); + + setupPageFilters(); beforeEach(() => { jest.resetAllMocks(); mockUseLocation.mockReturnValue(LocationFixture()); - PageFiltersStore.init(); - PageFiltersStore.onInitializeUrlState( - { - projects: [parseInt(project.id, 10)], - environments: [], - datetime: { - period: '14d', - start: null, - end: null, - utc: null, - }, - }, - new Set() - ); - requestAnimationFrameSpy = jest .spyOn(window, 'requestAnimationFrame') .mockImplementation((_callback: FrameRequestCallback): number => { @@ -87,21 +71,29 @@ describe('useVirtualStreaming', () => { // Data should be sorted by timestamp, so the first one should be the latest LogFixture({ [OurLogKnownFieldKey.ID]: '4', + [OurLogKnownFieldKey.PROJECT_ID]: project.id, + [OurLogKnownFieldKey.ORGANIZATION_ID]: Number(organization.id), // 42 seconds ago in nanoseconds [OurLogKnownFieldKey.TIMESTAMP_PRECISE]: String(BigInt(now - 42000) * 1_000_000n), }), LogFixture({ [OurLogKnownFieldKey.ID]: '3', + [OurLogKnownFieldKey.PROJECT_ID]: project.id, + [OurLogKnownFieldKey.ORGANIZATION_ID]: Number(organization.id), // 45 seconds ago in nanoseconds [OurLogKnownFieldKey.TIMESTAMP_PRECISE]: String(BigInt(now - 44000) * 1_000_000n), }), LogFixture({ [OurLogKnownFieldKey.ID]: '2', + [OurLogKnownFieldKey.PROJECT_ID]: project.id, + [OurLogKnownFieldKey.ORGANIZATION_ID]: Number(organization.id), // 50 seconds ago in nanoseconds [OurLogKnownFieldKey.TIMESTAMP_PRECISE]: String(BigInt(now - 46000) * 1_000_000n), }), LogFixture({ [OurLogKnownFieldKey.ID]: '1', + [OurLogKnownFieldKey.PROJECT_ID]: project.id, + [OurLogKnownFieldKey.ORGANIZATION_ID]: Number(organization.id), // 55 seconds ago in nanoseconds [OurLogKnownFieldKey.TIMESTAMP_PRECISE]: String(BigInt(now - 55000) * 1_000_000n), }), @@ -125,6 +117,8 @@ describe('useVirtualStreaming', () => { const mockData = createMockData([ LogFixture({ [OurLogKnownFieldKey.ID]: '1', + [OurLogKnownFieldKey.PROJECT_ID]: project.id, + [OurLogKnownFieldKey.ORGANIZATION_ID]: Number(organization.id), [OurLogKnownFieldKey.TIMESTAMP_PRECISE]: '1000000000000000000', }), ]); @@ -140,6 +134,8 @@ describe('useVirtualStreaming', () => { const mockData = createMockData([ LogFixture({ [OurLogKnownFieldKey.ID]: '1', + [OurLogKnownFieldKey.PROJECT_ID]: project.id, + [OurLogKnownFieldKey.ORGANIZATION_ID]: Number(organization.id), [OurLogKnownFieldKey.TIMESTAMP_PRECISE]: '1000000000000000000', }), ]); @@ -157,6 +153,8 @@ describe('useVirtualStreaming', () => { const mockData = createMockData([ LogFixture({ [OurLogKnownFieldKey.ID]: '1', + [OurLogKnownFieldKey.PROJECT_ID]: project.id, + [OurLogKnownFieldKey.ORGANIZATION_ID]: Number(organization.id), [OurLogKnownFieldKey.TIMESTAMP_PRECISE]: '1000000000000000000', }), ]); @@ -183,9 +181,13 @@ describe('useVirtualStreaming', () => { }); describe('isRowVisibleInVirtualStream', () => { + const {organization: testOrg, project: testProject} = initializeLogsTest(); + it('should filter based on the milliseconds of the passed timestamp', () => { const row = LogFixture({ [OurLogKnownFieldKey.ID]: '1', + [OurLogKnownFieldKey.PROJECT_ID]: testProject.id, + [OurLogKnownFieldKey.ORGANIZATION_ID]: Number(testOrg.id), [OurLogKnownFieldKey.TIMESTAMP_PRECISE]: '2000000000', }); diff --git a/static/app/views/explore/logs/useVirtualStreaming.tsx b/static/app/views/explore/logs/useVirtualStreaming.tsx index 78953ff491252e..5a4606654bf381 100644 --- a/static/app/views/explore/logs/useVirtualStreaming.tsx +++ b/static/app/views/explore/logs/useVirtualStreaming.tsx @@ -1,10 +1,11 @@ import {useCallback, useEffect, useRef, useState} from 'react'; +import isEqual from 'lodash/isEqual'; import type {ApiResult} from 'sentry/api'; import type {InfiniteData} from 'sentry/utils/queryClient'; import usePrevious from 'sentry/utils/usePrevious'; import { - useAutorefreshEnabledOrWithinPauseWindow, + useLogsAutoRefreshContinued, useLogsAutoRefreshEnabled, useLogsRefreshInterval, } from 'sentry/views/explore/contexts/logs/logsAutoRefreshContext'; @@ -53,8 +54,8 @@ export function useVirtualStreaming( data: InfiniteData> | undefined ) { const autoRefresh = useLogsAutoRefreshEnabled(); - const isAutoRefreshEnabledOrWithinPauseWindow = - useAutorefreshEnabledOrWithinPauseWindow(); + const previousAutoRefresh = usePrevious(autoRefresh); + const isAutoRefreshContinued = useLogsAutoRefreshContinued(); const refreshInterval = useLogsRefreshInterval(); const rafOn = useRef(false); const [virtualTimestamp, setVirtualTimestamp] = useState(undefined); @@ -74,7 +75,7 @@ export function useVirtualStreaming( // If we've received data, initialize the virtual timestamp to be refreshEvery seconds before the max ingest delay timestamp const initializeVirtualTimestamp = useCallback(() => { - if (!data?.pages?.length || virtualTimestamp !== undefined) { + if (!data?.pages?.length) { return; } @@ -107,20 +108,33 @@ export function useVirtualStreaming( ); setVirtualTimestamp(initialTimestamp); - }, [data, virtualTimestamp, refreshInterval]); + }, [data, refreshInterval]); - // Initialize when auto refresh is enabled and we have data useEffect(() => { - if (!isAutoRefreshEnabledOrWithinPauseWindow || virtualTimestamp !== undefined) { + if (!autoRefresh) { + return; + } + + if (virtualTimestamp === undefined) { + // First time enabling autorefresh, initialize virtual timestamp. + initializeVirtualTimestamp(); return; } - initializeVirtualTimestamp(); + if (isAutoRefreshContinued) { + // Re-enabling autorefresh with existing virtual timestamp, and within continue window, do nothing. + return; + } + if (!isEqual(autoRefresh, previousAutoRefresh)) { + // Re-enabling autorefresh with existing virtual timestamp, but outside continue window, reset virtual timestamp. + initializeVirtualTimestamp(); + } }, [ - isAutoRefreshEnabledOrWithinPauseWindow, + autoRefresh, initializeVirtualTimestamp, virtualTimestamp, - data?.pages?.length, + isAutoRefreshContinued, + previousAutoRefresh, ]); // Get the newest timestamp from the latest page to calculate how far behind we are diff --git a/tests/js/fixtures/log.ts b/tests/js/fixtures/log.ts index 41f651f6c9fe88..305a57f7f77588 100644 --- a/tests/js/fixtures/log.ts +++ b/tests/js/fixtures/log.ts @@ -1,5 +1,20 @@ +import pick from 'lodash/pick'; + +import {initializeOrg} from 'sentry-test/initializeOrg'; + +import PageFiltersStore from 'sentry/stores/pageFiltersStore'; +import ProjectsStore from 'sentry/stores/projectsStore'; +import type {PageFilters} from 'sentry/types/core'; +import type {Organization} from 'sentry/types/organization'; +import type {Project} from 'sentry/types/project'; import type {EventsMetaType} from 'sentry/utils/discover/eventView'; -import type {OurLogsResponseItem} from 'sentry/views/explore/logs/types'; +import {LOGS_REFRESH_INTERVAL_KEY} from 'sentry/views/explore/contexts/logs/logsAutoRefreshContext'; +import {LOGS_SORT_BYS_KEY} from 'sentry/views/explore/contexts/logs/sortBys'; +import type {TraceItemResponseAttribute} from 'sentry/views/explore/hooks/useTraceItemDetails'; +import type { + EventsLogsResult, + OurLogsResponseItem, +} from 'sentry/views/explore/logs/types'; import {OurLogKnownFieldKey} from 'sentry/views/explore/logs/types'; export function LogFixture({ @@ -13,7 +28,15 @@ export function LogFixture({ [OurLogKnownFieldKey.TRACE_ID]: traceId = '7b91699fd385d9fd52e0c4bc', [OurLogKnownFieldKey.TIMESTAMP_PRECISE]: timestampPrecise = 1.744312870049196e18, ...rest -}: Partial): OurLogsResponseItem { +}: Partial & + Required< + Pick< + OurLogsResponseItem, + | OurLogKnownFieldKey.ID + | OurLogKnownFieldKey.PROJECT_ID + | OurLogKnownFieldKey.ORGANIZATION_ID + > + >): OurLogsResponseItem { return { [OurLogKnownFieldKey.ID]: String(id), [OurLogKnownFieldKey.PROJECT_ID]: String(projectId), @@ -29,18 +52,321 @@ export function LogFixture({ } // Incomplete, only provides type of field if it's a string or number. -export function LogFixtureMeta(fixture: Partial): EventsMetaType { +export function LogFixtureMeta( + fixture: OurLogsResponseItem | OurLogsResponseItem[] +): EventsMetaType { + const logFixtures = Array.isArray(fixture) ? fixture : [fixture]; + const fields = logFixtures.flatMap(logFixture => + Object.entries(logFixture).map(([key, value]) => { + const valueType = typeof value; + if (!['string', 'number'].includes(valueType)) { + throw new Error(`Invalid value type: ${valueType}`); + } + return [key, valueType]; + }) + ); return { - fields: Object.fromEntries( - Object.entries(fixture).map(([key, value]) => { - const valueType = typeof value; - if (!['string', 'number'].includes(valueType)) { - throw new Error(`Invalid value type: ${valueType}`); - } - const type = valueType === 'string' ? 'string' : 'number'; - return [key, type]; - }) - ), + fields: Object.fromEntries(fields), units: {}, }; } + +interface LogsTestInitOptions { + liveRefresh?: boolean; + organization?: Partial; + ourlogs?: boolean; + pageFiltersPeriod?: string; + project?: Partial; + refreshInterval?: string; + routerQuery?: Record; +} + +// Narrowed type from sentry-test/reactTestingLibrary.tsx +type LocationConfig = { + pathname: string; + query?: Record; +}; + +/** + * Standardized initialization for logs tests + */ +export function initializeLogsTest({ + organization: orgOverrides = {}, + project: projectOverrides = {}, + ourlogs = true, + liveRefresh: hasLiveRefreshFlag = false, + routerQuery = {}, + refreshInterval = '60', // Fast refresh for testing, should always exceed waitFor internal timer interval (50ms default) +}: LogsTestInitOptions = {}): { + generateRouterConfig: (routerQueryOverrides: Record) => { + location: LocationConfig; + route?: string; + }; + initialLocation: LocationConfig; + initialPageFilters: PageFilters; + organization: Organization; + project: Project; + routerConfig: { + location: LocationConfig; + route?: string; + }; + setupEventsMock: (logFixtures: OurLogsResponseItem[]) => jest.Mock; + setupPageFilters: () => void; + setupTraceItemsMock: (logFixtures: OurLogsResponseItem[]) => jest.Mock[]; +} { + const baseFeatures = ourlogs + ? [ + 'ourlogs-enabled', + 'ourlogs-visualize-sidebar', + 'ourlogs-dashboard', + 'ourlogs-alerts', + 'ourlogs-infinite-scroll', + ] + : []; + + if (hasLiveRefreshFlag && ourlogs) { + baseFeatures.push('ourlogs-live-refresh'); + } + + const {organization, project} = initializeOrg({ + organization: { + features: baseFeatures, + ...orgOverrides, + }, + project: projectOverrides, + }); + + const initialLocation: LocationConfig = { + pathname: `/organizations/${organization.slug}/explore/logs/`, + query: { + [LOGS_SORT_BYS_KEY]: '-timestamp', + [LOGS_REFRESH_INTERVAL_KEY]: refreshInterval, // Fast refresh for testing, should always exceed waitFor internal timer interval (50ms default) + ...routerQuery, + }, + }; + + const routerConfig: { + location: LocationConfig; + route?: string; + } = { + location: initialLocation, + route: '/organizations/:orgId/explore/logs/', + }; + + const initialPageFilters: PageFilters = { + projects: [parseInt(project.id, 10)], + environments: [], + datetime: { + period: '14d', + start: null, + end: null, + utc: null, + }, + }; + + const generateRouterConfig = (routerQueryOverrides: Record) => { + return { + location: { + ...initialLocation, + query: { + ...initialLocation.query, + ...routerQueryOverrides, + }, + }, + }; + }; + + const setupPageFilters = () => { + ProjectsStore.loadInitialData([project]); + PageFiltersStore.init(); + PageFiltersStore.onInitializeUrlState(initialPageFilters, new Set()); + }; + + const setupEventsMock = (logFixtures: OurLogsResponseItem[]) => { + const eventsData: EventsLogsResult = { + data: logFixtures, + meta: LogFixtureMeta(logFixtures), + }; + + return MockApiClient.addMockResponse({ + url: `/organizations/${organization.slug}/events/`, + method: 'GET', + body: eventsData, + }); + }; + + const setupTraceItemsMock = (logFixtures: OurLogsResponseItem[]) => { + return logFixtures.map(logFixture => { + const attributes: TraceItemResponseAttribute[] = Object.entries(logFixture).map( + ([k, v]) => { + if (typeof v === 'string') { + return {name: k, value: v, type: 'str' as const}; + } + return {name: k, value: Number(v), type: 'float' as const}; + } + ); + + return MockApiClient.addMockResponse({ + url: `/projects/${organization.slug}/${project.slug}/trace-items/${logFixture[OurLogKnownFieldKey.ID]}/`, + method: 'GET', + body: { + itemId: logFixture[OurLogKnownFieldKey.ID], + links: null, + meta: LogFixtureMeta(logFixture), + timestamp: logFixture[OurLogKnownFieldKey.TIMESTAMP], + attributes, + }, + }); + }); + }; + + return { + organization, + project, + routerConfig, + initialPageFilters, + initialLocation, + generateRouterConfig, + setupPageFilters, + setupEventsMock, + setupTraceItemsMock, + }; +} + +/** + * Standard set of log fixtures for testing - can be sliced as needed + * Creates logs in descending timestamp order (newest first) + * Returns both base fixtures (minimal fields) and detailed fixtures (all fields) + */ +export function createLogFixtures( + organization: Organization, + project: Project, + nowDate: Date, + options: { + baseFields?: string[]; + intervalMs?: number; + } = {} +): { + baseFixtures: OurLogsResponseItem[]; + detailedFixtures: OurLogsResponseItem[]; +} { + const {intervalMs = 1000} = options; + const nowTimestamp = nowDate.getTime(); + + // Default base fields - minimal fields for basic testing + const defaultBaseFields = [ + OurLogKnownFieldKey.ID, + OurLogKnownFieldKey.PROJECT_ID, + OurLogKnownFieldKey.ORGANIZATION_ID, + OurLogKnownFieldKey.MESSAGE, + OurLogKnownFieldKey.SEVERITY, + OurLogKnownFieldKey.TIMESTAMP, + OurLogKnownFieldKey.TRACE_ID, + ]; + + const baseFieldKeys = options.baseFields || defaultBaseFields; + + const logData: Array>> = [ + { + [OurLogKnownFieldKey.ID]: '1', + [OurLogKnownFieldKey.PROJECT_ID]: project.id, + [OurLogKnownFieldKey.ORGANIZATION_ID]: Number(organization.id), + [OurLogKnownFieldKey.MESSAGE]: 'Error occurred in authentication service', + [OurLogKnownFieldKey.SEVERITY]: 'error', + [OurLogKnownFieldKey.SEVERITY_NUMBER]: 17, + [OurLogKnownFieldKey.TRACE_ID]: '7b91699fd385d9fd52e0c4bc', + [OurLogKnownFieldKey.RELEASE]: '1.0.0', + [OurLogKnownFieldKey.CODE_FILE_PATH]: + '/usr/local/lib/python3.11/dist-packages/gunicorn/glogging.py', + [OurLogKnownFieldKey.CODE_LINE_NUMBER]: 123, + [OurLogKnownFieldKey.SDK_NAME]: 'sentry.python', + [OurLogKnownFieldKey.SDK_VERSION]: '1.0.0', + }, + { + [OurLogKnownFieldKey.ID]: '2', + [OurLogKnownFieldKey.PROJECT_ID]: project.id, + [OurLogKnownFieldKey.ORGANIZATION_ID]: Number(organization.id), + [OurLogKnownFieldKey.MESSAGE]: 'User login successful', + [OurLogKnownFieldKey.SEVERITY]: 'info', + [OurLogKnownFieldKey.SEVERITY_NUMBER]: 9, + [OurLogKnownFieldKey.TRACE_ID]: '8c92799fe496e0ee63f1d5cd', + [OurLogKnownFieldKey.RELEASE]: '1.0.0', + [OurLogKnownFieldKey.CODE_FILE_PATH]: + '/usr/local/lib/python3.11/dist-packages/gunicorn/glogging.py', + [OurLogKnownFieldKey.CODE_LINE_NUMBER]: 456, + [OurLogKnownFieldKey.SDK_NAME]: 'sentry.python', + [OurLogKnownFieldKey.SDK_VERSION]: '1.0.0', + }, + { + [OurLogKnownFieldKey.ID]: '3', + [OurLogKnownFieldKey.PROJECT_ID]: project.id, + [OurLogKnownFieldKey.ORGANIZATION_ID]: Number(organization.id), + [OurLogKnownFieldKey.MESSAGE]: 'Database connection warning', + [OurLogKnownFieldKey.SEVERITY]: 'warn', + [OurLogKnownFieldKey.SEVERITY_NUMBER]: 13, + [OurLogKnownFieldKey.TRACE_ID]: '9d03800gf5a7f1ff74g2e6de', + [OurLogKnownFieldKey.RELEASE]: '1.0.1', + [OurLogKnownFieldKey.CODE_FILE_PATH]: + '/usr/local/lib/python3.11/dist-packages/gunicorn/glogging.py', + [OurLogKnownFieldKey.CODE_LINE_NUMBER]: 789, + [OurLogKnownFieldKey.SDK_NAME]: 'sentry.python', + [OurLogKnownFieldKey.SDK_VERSION]: '1.0.0', + }, + { + [OurLogKnownFieldKey.ID]: '4', + [OurLogKnownFieldKey.PROJECT_ID]: project.id, + [OurLogKnownFieldKey.ORGANIZATION_ID]: Number(organization.id), + [OurLogKnownFieldKey.MESSAGE]: 'Request processed successfully', + [OurLogKnownFieldKey.SEVERITY]: 'info', + [OurLogKnownFieldKey.SEVERITY_NUMBER]: 9, + [OurLogKnownFieldKey.TRACE_ID]: 'ae14911hg6b8g2gg85h3f7ef', + [OurLogKnownFieldKey.RELEASE]: '1.0.1', + [OurLogKnownFieldKey.CODE_FILE_PATH]: + '/usr/local/lib/python3.11/dist-packages/gunicorn/glogging.py', + [OurLogKnownFieldKey.CODE_LINE_NUMBER]: 321, + [OurLogKnownFieldKey.SDK_NAME]: 'sentry.python', + [OurLogKnownFieldKey.SDK_VERSION]: '1.0.0', + }, + { + [OurLogKnownFieldKey.ID]: '5', + [OurLogKnownFieldKey.PROJECT_ID]: project.id, + [OurLogKnownFieldKey.ORGANIZATION_ID]: Number(organization.id), + [OurLogKnownFieldKey.MESSAGE]: 'Debug trace information', + [OurLogKnownFieldKey.SEVERITY]: 'debug', + [OurLogKnownFieldKey.SEVERITY_NUMBER]: 5, + [OurLogKnownFieldKey.TRACE_ID]: 'bf25022ih7c9h3hh96i4g8fg', + [OurLogKnownFieldKey.RELEASE]: '1.0.1', + [OurLogKnownFieldKey.CODE_FILE_PATH]: + '/usr/local/lib/python3.11/dist-packages/gunicorn/glogging.py', + [OurLogKnownFieldKey.CODE_LINE_NUMBER]: 654, + [OurLogKnownFieldKey.SDK_NAME]: 'sentry.python', + [OurLogKnownFieldKey.SDK_VERSION]: '1.0.0', + }, + ]; + + const fixtures = logData.map((data, index) => { + const logTimestamp = nowTimestamp - index * intervalMs; + const completeLogData = { + ...data, + [OurLogKnownFieldKey.TIMESTAMP]: new Date(logTimestamp).toISOString(), + [OurLogKnownFieldKey.TIMESTAMP_PRECISE]: String(BigInt(logTimestamp) * 1_000_000n), + }; + return completeLogData; + }); + + // Used for /events mock + const baseFixtures = fixtures.map(completeLogData => { + const baseOnlyFields = pick(completeLogData, baseFieldKeys); + return LogFixture(baseOnlyFields as any); + }); + + // Used for /trace-items mock + const detailedFixtures = fixtures.map(completeLogData => { + return LogFixture(completeLogData as any); + }); + + return { + baseFixtures, + detailedFixtures, + }; +}