diff --git a/static/app/utils/dates.tsx b/static/app/utils/dates.tsx index 511db5e58acb9c..00e4263309ce07 100644 --- a/static/app/utils/dates.tsx +++ b/static/app/utils/dates.tsx @@ -8,7 +8,7 @@ export const DEFAULT_DAY_START_TIME = '00:00:00'; export const DEFAULT_DAY_END_TIME = '23:59:59'; const DATE_FORMAT_NO_TIMEZONE = 'YYYY/MM/DD HH:mm:ss'; -export function getParser(local = false): typeof moment | typeof moment.utc { +function getParser(local = false): typeof moment | typeof moment.utc { return local ? moment : moment.utc; } diff --git a/static/app/views/dashboards/widgets/heatMapWidget/heatMapWidgetVisualization.tsx b/static/app/views/dashboards/widgets/heatMapWidget/heatMapWidgetVisualization.tsx index 4a9bbd71d2d591..868341b53417b2 100644 --- a/static/app/views/dashboards/widgets/heatMapWidget/heatMapWidgetVisualization.tsx +++ b/static/app/views/dashboards/widgets/heatMapWidget/heatMapWidgetVisualization.tsx @@ -6,6 +6,7 @@ import {Flex} from '@sentry/scraps/layout'; import {BaseChart} from 'sentry/components/charts/baseChart'; import {usePageFilters} from 'sentry/components/pageFilters/usePageFilters'; +import {getUserTimezone} from 'sentry/utils/dates'; import {NO_PLOTTABLE_VALUES} from 'sentry/views/dashboards/widgets/common/settings'; import {plottablesCanBeVisualized} from 'sentry/views/dashboards/widgets/plottablesCanBeVisualized'; import {formatXAxisTimestamp} from 'sentry/views/dashboards/widgets/timeSeriesWidget/formatters/formatXAxisTimestamp'; @@ -32,6 +33,7 @@ export function HeatMapWidgetVisualization(props: HeatMapWidgetVisualizationProp const pageFilters = usePageFilters(); const {start, end, period, utc} = pageFilters.selection.datetime; + const timezone = utc ? 'UTC' : getUserTimezone(); if (!plottablesCanBeVisualized(plottables)) { throw new Error(NO_PLOTTABLE_VALUES); @@ -74,9 +76,7 @@ export function HeatMapWidgetVisualization(props: HeatMapWidgetVisualizationProp axisLabel: { formatter: value => { // NOTE: ECharts requires a `"category"` X-axis for heat maps, but we _know_ that we only support time as the X-axis. We need to parse the value here. - return formatXAxisTimestamp(parseFloat(value), { - utc: utc ?? undefined, - }); + return formatXAxisTimestamp(parseFloat(value), timezone); }, }, axisPointer: { diff --git a/static/app/views/dashboards/widgets/timeSeriesWidget/__stories__/makeRandomWalkTimeSeries.tsx b/static/app/views/dashboards/widgets/timeSeriesWidget/__stories__/makeRandomWalkTimeSeries.tsx new file mode 100644 index 00000000000000..0e21ce982d1388 --- /dev/null +++ b/static/app/views/dashboards/widgets/timeSeriesWidget/__stories__/makeRandomWalkTimeSeries.tsx @@ -0,0 +1,27 @@ +import type {TimeSeries} from 'sentry/views/dashboards/widgets/common/types'; + +/** + * Generate a TimeSeries for an arbitrary time range with a random-walk + * shape so charts look organic in stories. + */ +export function makeRandomWalkTimeSeries( + startMs: number, + endMs: number, + pointCount = 50 +): TimeSeries { + const interval = Math.max(Math.floor((endMs - startMs) / pointCount), 1); + const values: Array<{timestamp: number; value: number}> = []; + let current = 100; + + for (let ts = startMs; ts <= endMs; ts += interval) { + current += (Math.random() - 0.48) * 10; + current = Math.max(current, 1); + values.push({timestamp: ts, value: current}); + } + + return { + yAxis: 'count()', + meta: {valueType: 'number', valueUnit: null, interval}, + values, + }; +} diff --git a/static/app/views/dashboards/widgets/timeSeriesWidget/formatters/formatXAxisTimestamp.spec.tsx b/static/app/views/dashboards/widgets/timeSeriesWidget/formatters/formatXAxisTimestamp.spec.tsx index 3f1f760ca5adf5..d08a976675c6b7 100644 --- a/static/app/views/dashboards/widgets/timeSeriesWidget/formatters/formatXAxisTimestamp.spec.tsx +++ b/static/app/views/dashboards/widgets/timeSeriesWidget/formatters/formatXAxisTimestamp.spec.tsx @@ -8,14 +8,14 @@ import {formatXAxisTimestamp} from './formatXAxisTimestamp'; describe('formatXAxisTimestamp', () => { it.each([ // Year starts - ['2025-01-01T00:00:00', 'Jan 1st 2025'], - ['2024-01-01T00:00:00', 'Jan 1st 2024'], - // // Month starts + ['2025-01-01T00:00:00', '2025'], + ['2024-01-01T00:00:00', '2024'], + // Month starts ['2025-02-01T00:00:00', 'Feb 1st'], ['2024-03-01T00:00:00', 'Mar 1st'], - // // Day starts + // Day starts ['2025-02-05T00:00:00', 'Feb 5th'], - // // Hour starts + // Hour starts ['2025-02-05T12:00:00', '12:00 PM'], ['2025-02-05T05:00:00', '5:00 AM'], ['2025-02-01T01:00:00', '1:00 AM'], @@ -31,8 +31,8 @@ describe('formatXAxisTimestamp', () => { user.options.clock24Hours = false; ConfigStore.set('user', user); - const timestamp = moment(raw).unix() * 1000; - expect(formatXAxisTimestamp(timestamp)).toEqual(formatted); + const timestamp = moment.tz(raw, 'UTC').valueOf(); + expect(formatXAxisTimestamp(timestamp, 'UTC')).toEqual(formatted); }); it.each([ @@ -48,7 +48,7 @@ describe('formatXAxisTimestamp', () => { user.options.clock24Hours = true; ConfigStore.set('user', user); - const timestamp = moment(raw).unix() * 1000; - expect(formatXAxisTimestamp(timestamp)).toEqual(formatted); + const timestamp = moment.tz(raw, 'UTC').valueOf(); + expect(formatXAxisTimestamp(timestamp, 'UTC')).toEqual(formatted); }); }); diff --git a/static/app/views/dashboards/widgets/timeSeriesWidget/formatters/formatXAxisTimestamp.tsx b/static/app/views/dashboards/widgets/timeSeriesWidget/formatters/formatXAxisTimestamp.tsx index 84d96c862003fa..0882b1ac6e853a 100644 --- a/static/app/views/dashboards/widgets/timeSeriesWidget/formatters/formatXAxisTimestamp.tsx +++ b/static/app/views/dashboards/widgets/timeSeriesWidget/formatters/formatXAxisTimestamp.tsx @@ -1,29 +1,42 @@ -import {getParser, getTimeFormat} from 'sentry/utils/dates'; +import moment from 'moment-timezone'; + +import {getTimeFormat} from 'sentry/utils/dates'; /** - * A "cascading" formatter, based on the recommendations in [ECharts documentation](https://echarts.apache.org/en/option.html#xAxis.axisLabel.formatter). Given a timestamp of an X axis of type `"time"`, return a formatted string, to show under the axis tick. + * A cascading formatter for time axis labels. Given a tick timestamp, returns + * a formatted string whose granularity matches the tick's position. This + * works by inspecting what "round boundary" the tick falls on: + * + * - Midnight on Jan 1 → just the year: "2025" + * - Midnight on any day → date only: "Feb 3rd" + * - Any round minute → time only: "2:00 PM" + * - Otherwise → time with seconds: "2:00:30 PM" + * + * This approach is stateless — each tick is formatted independently, without + * knowledge of the other ticks. It relies on tick positions landing on round + * boundaries, which is guaranteed by both ECharts' built-in tick placement + * and by {@link generateTimezoneAlignedTicks} (which provides timezone-aware + * custom tick positions via `customValues`). * - * The fidelity of the formatted value depends on the fidelity of the tick mark timestamp. ECharts will intelligently choose the location of tick marks based on the total time range, and any significant intervals inside. It always chooses tick marks that fall on a "round" time values (starts of days, starts of hours, 15 minute intervals, etc.). This formatter is called on the time stamps of the selected ticks. Here are some examples of output labels sets you can expect: + * The tick value is interpreted in the given timezone. This is important + * because tick positions from `generateTimezoneAlignedTicks` are at round + * boundaries in the user's timezone (e.g., midnight IST), not in the + * browser's local timezone. Without timezone-aware parsing, those ticks + * would be displayed as non-round browser-local times (e.g., "10:30 AM" + * PST instead of "12:00 AM" IST). * - * ["Feb 1st", "Feb 2nd", "Feb 3rd"] when ECharts aligns ticks with days of the month - * ["11:00pm", "Feb 2nd", "1:00am"] when ECharts aligns ticks with hours across a day boundary - * ["Mar 1st", "Apr 1st", "May 1st"] when ECharts aligns ticks with starts of month - * ["Dec 1st", "Jan 1st 2025", "Feb 1st"] when ECharts aligns markers with starts of month across a year boundary - * ["12:00pm", "1:00am", "2:00am", "3:00am"] when ECharts aligns ticks with hours starts + * Example label sets for different time ranges: * - * @param value - * @param options - * @returns Formatted X axis label string + * - Days: ["Feb 1st", "Feb 2nd", "Feb 3rd"] + * - Hours across a day boundary: ["11:00 PM", "Feb 2nd", "1:00 AM"] + * - Months: ["Mar 1st", "Apr 1st", "May 1st"] + * - Months across a year boundary: ["Dec 1st", "2025", "Feb 1st"] + * - Hours: ["12:00 PM", "1:00 AM", "2:00 AM", "3:00 AM"] */ -export function formatXAxisTimestamp( - value: number, - options: {utc?: boolean} = {utc: false} -): string { - const parsed = getParser(!options.utc)(value); +export function formatXAxisTimestamp(value: number, timezone: string): string { + const parsed = moment.tz(value, timezone); - // Granularity-aware parsing, adjusts the format based on the - // granularity of the object This works well with ECharts since the - // parser is not aware of the other ticks + // Cascade from most specific to least specific boundary let format = 'MMM Do'; if ( @@ -32,8 +45,8 @@ export function formatXAxisTimestamp( parsed.minute() === 0 && parsed.second() === 0 ) { - // Start of a year - format = 'MMM Do YYYY'; + // Start of a year — just show the year (e.g., "2025") + format = 'YYYY'; } else if ( parsed.day() === 0 && parsed.hour() === 0 && diff --git a/static/app/views/dashboards/widgets/timeSeriesWidget/generateTimezoneAlignedTicks.spec.ts b/static/app/views/dashboards/widgets/timeSeriesWidget/generateTimezoneAlignedTicks.spec.ts new file mode 100644 index 00000000000000..13edc0e6599709 --- /dev/null +++ b/static/app/views/dashboards/widgets/timeSeriesWidget/generateTimezoneAlignedTicks.spec.ts @@ -0,0 +1,271 @@ +import moment from 'moment-timezone'; + +import {generateTimezoneAlignedTicks} from './generateTimezoneAlignedTicks'; + +describe('generateTimezoneAlignedTicks', () => { + describe('interval selection', () => { + it.each([ + [0, 5 * 60 * 1000, 'minute'], // 5 minutes + [0, 3600 * 1000, 'minute'], // 1 hour + [0, 6 * 3600 * 1000, 'hour'], // 6 hours + [0, 24 * 3600 * 1000, 'hour'], // 24 hours + [0, 7 * 86400 * 1000, 'day'], // 7 days + [0, 30 * 86400 * 1000, 'day'], // 30 days + [0, 90 * 86400 * 1000, 'month'], // 90 days + [0, 365 * 86400 * 1000, 'month'], // 365 days + [0, 3 * 365 * 86400 * 1000, 'year'], // 3 years + ])( + 'selects %s-level ticks for offset %d to %d', + (startOffset, endOffset, expectUnit) => { + const base = Date.UTC(2025, 0, 1); + const ticks = generateTimezoneAlignedTicks( + base + startOffset, + base + endOffset, + 5, + 'UTC' + ); + + expect(ticks.length).toBeGreaterThan(0); + + for (const tick of ticks) { + const m = moment.utc(tick); + if (expectUnit === 'year') { + expect(m.month()).toBe(0); + expect(m.date()).toBe(1); + expect(m.hour()).toBe(0); + } else if (expectUnit === 'month') { + expect(m.date()).toBe(1); + expect(m.hour()).toBe(0); + } else if (expectUnit === 'day') { + expect(m.hour()).toBe(0); + expect(m.minute()).toBe(0); + } else if (expectUnit === 'hour') { + expect(m.minute()).toBe(0); + expect(m.second()).toBe(0); + } else if (expectUnit === 'minute') { + expect(m.second()).toBe(0); + } + } + } + ); + }); + + describe('timezone alignment', () => { + it('places ticks at PST midnight boundaries for America/Los_Angeles', () => { + const tz = 'America/Los_Angeles'; + const start = toMs('2025-01-15 00:00:00', tz); + const end = toMs('2025-01-16 00:00:00', tz); + + const ticks = generateTimezoneAlignedTicks(start, end, 5, tz); + + for (const tick of ticks) { + const m = moment.tz(tick, tz); + expect(m.minute()).toBe(0); + expect(m.second()).toBe(0); + } + + const ticksFormatted = ticks.map(t => formatInTz(t, tz, 'HH:mm')); + expect(ticksFormatted).toContain('00:00'); + }); + + it('places ticks at IST boundaries for Asia/Kolkata (UTC+5:30)', () => { + const tz = 'Asia/Kolkata'; + const start = toMs('2025-01-15 00:00:00', tz); + const end = toMs('2025-01-16 00:00:00', tz); + + const ticks = generateTimezoneAlignedTicks(start, end, 5, tz); + + for (const tick of ticks) { + const m = moment.tz(tick, tz); + expect(m.minute()).toBe(0); + expect(m.second()).toBe(0); + } + + // IST midnight = 18:30 UTC previous day — verify UTC timestamps have :30 minutes + const midnightTick = ticks.find(t => moment.tz(t, tz).hour() === 0); + if (midnightTick) { + expect(moment.utc(midnightTick).minute()).toBe(30); + } + }); + + it('places ticks at UTC round boundaries when timezone is UTC', () => { + const start = Date.UTC(2025, 0, 15, 0, 0, 0); + const end = Date.UTC(2025, 0, 16, 0, 0, 0); + + const ticks = generateTimezoneAlignedTicks(start, end, 5, 'UTC'); + + for (const tick of ticks) { + const m = moment.utc(tick); + expect(m.minute()).toBe(0); + expect(m.second()).toBe(0); + } + }); + }); + + describe('DST transitions', () => { + it('handles spring forward (America/New_York, March 2025)', () => { + const tz = 'America/New_York'; + // DST transition: March 9, 2025 at 2:00 AM → 3:00 AM + const start = toMs('2025-03-07 00:00:00', tz); + const end = toMs('2025-03-12 00:00:00', tz); + + const ticks = generateTimezoneAlignedTicks(start, end, 5, tz); + + for (const tick of ticks) { + const m = moment.tz(tick, tz); + expect(m.hour()).toBe(0); + expect(m.minute()).toBe(0); + } + + // Check that the gap between ticks spanning DST is 23 hours (not 24) + const march9 = ticks.find(t => moment.tz(t, tz).date() === 9); + const march10 = ticks.find(t => moment.tz(t, tz).date() === 10); + if (march9 && march10) { + const gapHours = (march10 - march9) / (3600 * 1000); + expect(gapHours).toBe(23); // Spring forward: 23 hours between midnights + } + }); + + it('handles fall back (America/New_York, November 2025)', () => { + const tz = 'America/New_York'; + // DST transition: November 2, 2025 at 2:00 AM → 1:00 AM + const start = toMs('2025-10-31 00:00:00', tz); + const end = toMs('2025-11-05 00:00:00', tz); + + const ticks = generateTimezoneAlignedTicks(start, end, 5, tz); + + for (const tick of ticks) { + const m = moment.tz(tick, tz); + expect(m.hour()).toBe(0); + expect(m.minute()).toBe(0); + } + + // Check that the gap spanning fall back is 25 hours + const nov2 = ticks.find(t => moment.tz(t, tz).date() === 2); + const nov3 = ticks.find(t => moment.tz(t, tz).date() === 3); + if (nov2 && nov3) { + const gapHours = (nov3 - nov2) / (3600 * 1000); + expect(gapHours).toBe(25); // Fall back: 25 hours between midnights + } + }); + }); + + describe('half-hour timezones', () => { + it('handles Asia/Kolkata (+5:30) multi-day span', () => { + const tz = 'Asia/Kolkata'; + const start = toMs('2025-01-10 00:00:00', tz); + const end = toMs('2025-01-20 00:00:00', tz); + + const ticks = generateTimezoneAlignedTicks(start, end, 5, tz); + + for (const tick of ticks) { + const m = moment.tz(tick, tz); + expect(m.hour()).toBe(0); + expect(m.minute()).toBe(0); + } + }); + + it('handles Asia/Kathmandu (+5:45)', () => { + const tz = 'Asia/Kathmandu'; + const start = toMs('2025-01-15 00:00:00', tz); + const end = toMs('2025-01-16 00:00:00', tz); + + const ticks = generateTimezoneAlignedTicks(start, end, 5, tz); + + for (const tick of ticks) { + const m = moment.tz(tick, tz); + expect(m.minute()).toBe(0); + expect(m.second()).toBe(0); + } + }); + }); + + describe('edge cases', () => { + it('returns empty array when start equals end', () => { + const ts = Date.UTC(2025, 0, 15); + expect(generateTimezoneAlignedTicks(ts, ts, 5, 'UTC')).toEqual([]); + }); + + it('returns empty array when start > end', () => { + const start = Date.UTC(2025, 0, 16); + const end = Date.UTC(2025, 0, 15); + expect(generateTimezoneAlignedTicks(start, end, 5, 'UTC')).toEqual([]); + }); + + it('returns empty array when splitNumber is 0', () => { + const start = Date.UTC(2025, 0, 15); + const end = Date.UTC(2025, 0, 16); + expect(generateTimezoneAlignedTicks(start, end, 0, 'UTC')).toEqual([]); + }); + + it('handles very long span (>10 years)', () => { + const start = Date.UTC(2015, 0, 1); + const end = Date.UTC(2026, 0, 1); + + const ticks = generateTimezoneAlignedTicks(start, end, 5, 'UTC'); + + expect(ticks.length).toBeGreaterThan(0); + expect(ticks.length).toBeLessThan(20); + + for (const tick of ticks) { + const m = moment.utc(tick); + expect(m.month()).toBe(0); + expect(m.date()).toBe(1); + } + }); + }); + + describe('tick count', () => { + it.each([ + [0, 3600 * 1000], // 1 hour + [0, 24 * 3600 * 1000], // 24 hours + [0, 7 * 86400 * 1000], // 7 days + [0, 30 * 86400 * 1000], // 30 days + [0, 90 * 86400 * 1000], // 90 days + ])( + 'produces roughly splitNumber ticks for offset %d to %d', + (startOffset, endOffset) => { + const base = Date.UTC(2025, 0, 1); + const splitNumber = 5; + const ticks = generateTimezoneAlignedTicks( + base + startOffset, + base + endOffset, + splitNumber, + 'UTC' + ); + + // Allow splitNumber ± 3 + expect(ticks.length).toBeGreaterThanOrEqual(splitNumber - 3); + expect(ticks.length).toBeLessThanOrEqual(splitNumber + 3); + } + ); + }); + + describe('tick ordering and range', () => { + it('returns ticks in ascending order within [start, end]', () => { + const start = Date.UTC(2025, 0, 1); + const end = Date.UTC(2025, 1, 1); + + const ticks = generateTimezoneAlignedTicks(start, end, 5, 'America/New_York'); + + for (let i = 1; i < ticks.length; i++) { + expect(ticks[i]).toBeGreaterThan(ticks[i - 1]!); + } + + for (const tick of ticks) { + expect(tick).toBeGreaterThanOrEqual(start); + expect(tick).toBeLessThanOrEqual(end); + } + }); + }); +}); + +/** Convert a timezone-local datetime string to UTC milliseconds. */ +function toMs(dateStr: string, timezone: string): number { + return moment.tz(dateStr, timezone).valueOf(); +} + +/** Format a UTC ms timestamp in a timezone for readable assertions. */ +function formatInTz(ms: number, timezone: string, fmt = 'YYYY-MM-DD HH:mm:ss'): string { + return moment.tz(ms, timezone).format(fmt); +} diff --git a/static/app/views/dashboards/widgets/timeSeriesWidget/generateTimezoneAlignedTicks.ts b/static/app/views/dashboards/widgets/timeSeriesWidget/generateTimezoneAlignedTicks.ts new file mode 100644 index 00000000000000..79ccb27e3163c2 --- /dev/null +++ b/static/app/views/dashboards/widgets/timeSeriesWidget/generateTimezoneAlignedTicks.ts @@ -0,0 +1,187 @@ +import * as Sentry from '@sentry/react'; +import moment from 'moment-timezone'; + +type TimeUnit = 'second' | 'minute' | 'hour' | 'day' | 'month' | 'year'; + +/** + * Generate timezone-aligned tick positions for an ECharts time axis. + * + * ECharts can only place ticks at browser-local or UTC round boundaries. + * When the user's configured timezone differs from the browser timezone, + * ticks appear at non-round times (e.g., every tick at "9:30 PM" instead + * of "12:00 AM"). This function computes tick positions at round boundaries + * in the user's timezone, for use with ECharts' `customValues` option on + * both `axisTick` and `axisLabel`. + * + * Unlike ECharts' built-in multi-level tick generation (which builds a + * hierarchy of year → month → day → hour ticks and assigns each a `level` + * for formatting), this uses a simpler flat, single-pass approach: pick one + * (unit, step) interval, snap to the nearest round boundary, and walk + * forward. This works because label formatting is handled separately by + * {@link formatXAxisTimestamp}, which inspects each tick value and cascades + * through format levels based on what round boundary it falls on (e.g., a + * tick at midnight Jan 1 gets "2025", a tick at midnight gets + * "Feb 3rd", a tick at 2:00 PM gets "2:00 PM"). The combination of flat + * tick generation + cascading formatter produces the same mixed-granularity + * labels as ECharts' hierarchy (e.g., "2025 | Feb | Mar | Apr"). + * + * @param startMs Start of the time range (UTC milliseconds) + * @param endMs End of the time range (UTC milliseconds) + * @param splitNumber Desired number of ticks (approximate) + * @param timezone IANA timezone string (e.g., 'America/New_York') + * @returns Array of UTC millisecond timestamps for tick positions + */ +export function generateTimezoneAlignedTicks( + startMs: number, + endMs: number, + splitNumber: number, + timezone: string +): number[] { + if (endMs <= startMs || splitNumber <= 0) { + return []; + } + + const start = performance.now(); + + const {unit, step} = pickInterval(startMs, endMs, splitNumber); + const cursor = snapToRoundBoundary(startMs, unit, step, timezone); + const ticks: number[] = []; + + // Safety limit to prevent infinite loops + const maxIterations = splitNumber * 10; + let iterations = 0; + + while (cursor.valueOf() <= endMs && iterations < maxIterations) { + if (cursor.valueOf() >= startMs) { + ticks.push(cursor.valueOf()); + } + cursor.add(step, unit); + iterations++; + } + + Sentry.metrics.distribution( + 'dashboards.widget.generate_timezone_aligned_ticks', + performance.now() - start, + { + unit: 'millisecond', + attributes: {interval_unit: unit, tick_count: String(ticks.length)}, + } + ); + + return ticks; +} + +/** + * Durations in milliseconds for each time unit. Month and year use nominal + * values (30d, 365d) since exact durations vary — this is only used for + * picking the right order-of-magnitude interval, not for precise arithmetic. + */ +const UNIT_DURATIONS: Record = { + second: 1000, + minute: 60 * 1000, + hour: 3600 * 1000, + day: 86400 * 1000, + month: 30 * 86400 * 1000, + year: 365 * 86400 * 1000, +}; + +/** + * Mirrors ECharts' `scaleIntervals` (echarts/src/scale/Time.ts:281-306). + * Each (unit, step) pair represents a possible tick interval, ordered from + * finest to coarsest granularity. + */ +const INTERVAL_LEVELS: Array<{steps: number[]; unit: TimeUnit}> = [ + {unit: 'second', steps: [1, 2, 5, 10, 15, 20, 30]}, + {unit: 'minute', steps: [1, 2, 5, 10, 15, 20, 30]}, + {unit: 'hour', steps: [1, 2, 4, 6, 12]}, + {unit: 'day', steps: [1, 2, 4, 7, 16]}, + {unit: 'month', steps: [1, 2, 3, 6]}, + {unit: 'year', steps: [1]}, +]; + +/** + * Flattened and sorted list of all (unit, step) pairs with their + * durations, for efficient interval selection. + */ +const SORTED_INTERVALS: Array<{duration: number; step: number; unit: TimeUnit}> = + INTERVAL_LEVELS.flatMap(({unit, steps}) => + steps.map(step => ({ + unit, + step, + duration: UNIT_DURATIONS[unit] * step, + })) + ).sort((a, b) => a.duration - b.duration); + +/** + * Pick the best (unit, step) interval for a given time range and desired + * number of ticks. + */ +function pickInterval( + startMs: number, + endMs: number, + splitNumber: number +): {step: number; unit: TimeUnit} { + const approxInterval = (endMs - startMs) / splitNumber; + + for (const {unit, step} of SORTED_INTERVALS) { + if (UNIT_DURATIONS[unit] * step >= approxInterval) { + return {unit, step}; + } + } + + // Fallback to the coarsest interval (1 year) + return {unit: 'year', step: 1}; +} + +/** + * Snap a timestamp down to the nearest round boundary for the given + * unit and step, in the specified timezone. + * + * For example, with unit='hour' and step=6, a timestamp at 14:30 IST + * would snap down to 12:00 IST. + */ +function snapToRoundBoundary( + ms: number, + unit: TimeUnit, + step: number, + timezone: string +): moment.Moment { + const m = moment.tz(ms, timezone); + + switch (unit) { + case 'year': + m.year(Math.floor(m.year() / step) * step) + .month(0) + .date(1) + .startOf('day'); + break; + case 'month': + m.month(Math.floor(m.month() / step) * step) + .date(1) + .startOf('day'); + break; + case 'day': { + const snappedDate = Math.floor((m.date() - 1) / step) * step + 1; + m.date(snappedDate).startOf('day'); + break; + } + case 'hour': + m.hour(Math.floor(m.hour() / step) * step) + .minute(0) + .second(0) + .millisecond(0); + break; + case 'minute': + m.minute(Math.floor(m.minute() / step) * step) + .second(0) + .millisecond(0); + break; + case 'second': + m.second(Math.floor(m.second() / step) * step).millisecond(0); + break; + default: + break; + } + + return m; +} diff --git a/static/app/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidgetVisualization.stories.tsx b/static/app/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidgetVisualization.stories.tsx index 9f16c0935fb210..7f1524b674c93e 100644 --- a/static/app/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidgetVisualization.stories.tsx +++ b/static/app/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidgetVisualization.stories.tsx @@ -8,6 +8,7 @@ import {Button} from '@sentry/scraps/button'; import {CodeBlock} from '@sentry/scraps/code'; import {ExternalLink} from '@sentry/scraps/link'; +import {ConfigStore} from 'sentry/stores/configStore'; import * as Storybook from 'sentry/stories'; import type {DateString} from 'sentry/types/core'; import {DurationUnit, RateUnit} from 'sentry/utils/discover/fields'; @@ -20,6 +21,7 @@ import type { TimeSeriesMeta, } from 'sentry/views/dashboards/widgets/common/types'; +import {makeRandomWalkTimeSeries} from './__stories__/makeRandomWalkTimeSeries'; import {shiftTabularDataToNow} from './__stories__/shiftTabularDataToNow'; import {shiftTimeSeriesToNow} from './__stories__/shiftTimeSeriesToNow'; import {sampleCrashFreeRateTimeSeries} from './fixtures/sampleCrashFreeRateTimeSeries'; @@ -487,6 +489,75 @@ export default Storybook.story('TimeSeriesWidgetVisualization', story => { ); }); + story('X-Axis Ticks', () => { + // Simulate a Sentry timezone that differs from the browser timezone. + // This is the scenario that causes misaligned ticks without our fix: + // the browser might be in e.g. America/Los_Angeles, but the user's + // Sentry account is configured to Asia/Kolkata (UTC+5:30). + const simulatedTimezone = 'Asia/Kolkata'; + const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + + useEffect(() => { + const previousUser = ConfigStore.get('user'); + ConfigStore.set('user', { + ...previousUser, + options: { + ...previousUser?.options, + timezone: simulatedTimezone, + }, + }); + + return () => { + ConfigStore.set('user', previousUser); + }; + }, []); + + return ( + +

+ places X axis ticks + at round boundaries in the user's configured timezone, regardless of the + browser's local timezone. For example, if a user's Sentry account is set to{' '} + Asia/Kolkata (UTC+5:30) but their browser is in{' '} + America/Los_Angeles, ticks will still land at round hours like + 12:00 AM, 6:00 AM, etc. in IST — not at odd offsets like 6:30 PM. +

+ +

+ This works in two parts. First, generateTimezoneAlignedTicks picks + an interval from the same table ECharts uses (1h, 6h, 1d, etc.), snaps to the + nearest round boundary in the user's timezone, and walks forward to produce tick + positions. Then, formatXAxisTimestamp formats each tick by + inspecting what round boundary it falls on — a tick at midnight Jan 1 gets + "2025", midnight on any other day gets "Feb 3rd", and a tick on a round hour + gets "2:00 PM". This stateless cascading produces mixed-granularity labels + (e.g., "2025 | Feb | Mar") without needing ECharts' built-in multi-level tick + hierarchy. +

+ +

+ Simulated timezone mismatch: Browser is{' '} + {browserTimezone}, user timezone is overridden to{' '} + {simulatedTimezone} (UTC+5:30). All tick labels below should show + round times. +

+ + + {X_AXIS_TICK_TEST_CASES.map(({label, startMs, endMs}) => ( +
+ {label} +
+ +
+
+ ))} +
+
+ ); + }); + story('Unit Alignment', () => { const millisecondsSeries = sampleDurationTimeSeries; @@ -1274,3 +1345,86 @@ const NULL_META: TimeSeriesMeta = { valueUnit: null, interval: 0, }; + +const MINUTE = 60 * 1000; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; + +// Base timestamp: Jan 15, 2025 00:00 UTC +const TICK_STORY_BASE = Date.UTC(2025, 0, 15, 0, 0, 0); + +const X_AXIS_TICK_TEST_CASES: Array<{endMs: number; label: string; startMs: number}> = [ + // Standard ranges + {label: '30 seconds', startMs: TICK_STORY_BASE, endMs: TICK_STORY_BASE + 30 * 1000}, + {label: '5 minutes', startMs: TICK_STORY_BASE, endMs: TICK_STORY_BASE + 5 * MINUTE}, + {label: '15 minutes', startMs: TICK_STORY_BASE, endMs: TICK_STORY_BASE + 15 * MINUTE}, + {label: '1 hour', startMs: TICK_STORY_BASE, endMs: TICK_STORY_BASE + HOUR}, + {label: '6 hours', startMs: TICK_STORY_BASE, endMs: TICK_STORY_BASE + 6 * HOUR}, + {label: '12 hours', startMs: TICK_STORY_BASE, endMs: TICK_STORY_BASE + 12 * HOUR}, + {label: '24 hours', startMs: TICK_STORY_BASE, endMs: TICK_STORY_BASE + DAY}, + {label: '3 days', startMs: TICK_STORY_BASE, endMs: TICK_STORY_BASE + 3 * DAY}, + {label: '7 days', startMs: TICK_STORY_BASE, endMs: TICK_STORY_BASE + 7 * DAY}, + {label: '14 days', startMs: TICK_STORY_BASE, endMs: TICK_STORY_BASE + 14 * DAY}, + {label: '30 days', startMs: TICK_STORY_BASE, endMs: TICK_STORY_BASE + 30 * DAY}, + {label: '90 days', startMs: TICK_STORY_BASE, endMs: TICK_STORY_BASE + 90 * DAY}, + {label: '6 months', startMs: TICK_STORY_BASE, endMs: TICK_STORY_BASE + 182 * DAY}, + {label: '1 year', startMs: TICK_STORY_BASE, endMs: TICK_STORY_BASE + 365 * DAY}, + {label: '3 years', startMs: TICK_STORY_BASE, endMs: TICK_STORY_BASE + 3 * 365 * DAY}, + + // DST edge cases (America/New_York: spring forward March 9 2025, fall back Nov 2 2025) + { + label: 'DST spring forward (hours)', + startMs: Date.UTC(2025, 2, 9, 4, 0, 0), + endMs: Date.UTC(2025, 2, 9, 12, 0, 0), + }, + { + label: 'DST spring forward (days)', + startMs: Date.UTC(2025, 2, 1, 5, 0, 0), + endMs: Date.UTC(2025, 2, 15, 4, 0, 0), + }, + { + label: 'DST fall back (hours)', + startMs: Date.UTC(2025, 10, 2, 2, 0, 0), + endMs: Date.UTC(2025, 10, 2, 12, 0, 0), + }, + { + label: 'DST fall back (days)', + startMs: Date.UTC(2025, 9, 25, 4, 0, 0), + endMs: Date.UTC(2025, 10, 10, 5, 0, 0), + }, + + // Boundary rollover — shows how the cascading formatter produces + // mixed-granularity labels at day, month, and year transitions + { + label: 'Day boundary (hours across midnight)', + startMs: Date.UTC(2025, 0, 15, 18, 0, 0), + endMs: Date.UTC(2025, 0, 16, 6, 0, 0), + }, + { + label: 'Month boundary (days across month end)', + startMs: Date.UTC(2025, 0, 25, 18, 30, 0), + endMs: Date.UTC(2025, 1, 5, 18, 30, 0), + }, + { + label: 'Year boundary (days across New Year)', + startMs: Date.UTC(2024, 11, 25, 18, 30, 0), + endMs: Date.UTC(2025, 0, 5, 18, 30, 0), + }, + { + label: 'Year boundary (months across New Year)', + startMs: Date.UTC(2024, 9, 1, 18, 30, 0), + endMs: Date.UTC(2025, 2, 1, 18, 30, 0), + }, + + // Unusual ranges + { + label: 'Non-aligned start (14:37 UTC, 6h)', + startMs: Date.UTC(2025, 0, 15, 14, 37, 22), + endMs: Date.UTC(2025, 0, 15, 20, 37, 22), + }, + { + label: 'Cross-year boundary', + startMs: Date.UTC(2024, 11, 20, 0, 0, 0), + endMs: Date.UTC(2025, 0, 10, 0, 0, 0), + }, +]; diff --git a/static/app/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidgetVisualization.tsx b/static/app/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidgetVisualization.tsx index 6eac8736992a4d..60f83fdd03591a 100644 --- a/static/app/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidgetVisualization.tsx +++ b/static/app/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidgetVisualization.tsx @@ -34,6 +34,7 @@ import type { } from 'sentry/types/echarts'; import {defined, escape} from 'sentry/utils'; import {uniq} from 'sentry/utils/array/uniq'; +import {getUserTimezone} from 'sentry/utils/dates'; import type {AggregationOutputType} from 'sentry/utils/discover/fields'; import {RangeMap, type Range} from 'sentry/utils/number/rangeMap'; import {useLocation} from 'sentry/utils/useLocation'; @@ -55,6 +56,7 @@ import {formatTooltipValue} from './formatters/formatTooltipValue'; import {formatXAxisTimestamp} from './formatters/formatXAxisTimestamp'; import {formatYAxisValue} from './formatters/formatYAxisValue'; import type {Plottable} from './plottables/plottable'; +import {generateTimezoneAlignedTicks} from './generateTimezoneAlignedTicks'; import {ReleaseSeries} from './releaseSeries'; import {FALLBACK_TYPE, FALLBACK_UNIT_FOR_FIELD_TYPE} from './settings'; import {TimeSeriesWidgetYAxis} from './timeSeriesWidgetYAxis'; @@ -500,6 +502,28 @@ export function TimeSeriesWidgetVisualization(props: TimeSeriesWidgetVisualizati const showXAxisProp = props.showXAxis ?? 'auto'; const showXAxis = showXAxisProp === 'auto'; + // ECharts can only snap axis ticks to browser-local or UTC boundaries. When + // the user's configured timezone differs from the browser timezone, ticks + // land at non-round times. Compute custom tick positions aligned to the + // user's timezone and pass them via ECharts' `customValues` option on both + // `axisTick` (tick mark positions) and `axisLabel` (label positions) — these + // are independent in ECharts and must both be set. The label formatter + // (`formatXAxisTimestamp`) then cascades through format levels based on what + // round boundary each tick falls on, producing mixed-granularity labels. + const startMilliseconds = earliestTimeStamp + ? new Date(earliestTimeStamp).getTime() + : undefined; + const endMilliseconds = latestTimeStamp + ? new Date(latestTimeStamp).getTime() + : undefined; + const timezone = utc ? 'UTC' : getUserTimezone(); + const customTicks = useMemo(() => { + if (startMilliseconds === undefined || endMilliseconds === undefined) { + return; + } + return generateTimezoneAlignedTicks(startMilliseconds, endMilliseconds, 10, timezone); + }, [startMilliseconds, endMilliseconds, timezone]); + const xAxis = showXAxis ? { animation: false, @@ -507,8 +531,12 @@ export function TimeSeriesWidgetVisualization(props: TimeSeriesWidgetVisualizati padding: [0, 10, 0, 10], width: 60, formatter: (value: number) => { - return formatXAxisTimestamp(value, {utc: utc ?? undefined}); + return formatXAxisTimestamp(value, timezone); }, + ...(customTicks ? {customValues: customTicks} : {}), + }, + axisTick: { + ...(customTicks ? {customValues: customTicks} : {}), }, splitNumber: 5, ...releaseBubbleXAxis,