From 35800015b0bfcb71efdbdeb9293dcf5a59e40871 Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Wed, 30 Sep 2026 01:44:55 +0200 Subject: [PATCH 01/48] feat(sql-editor): lay out BI mode like a desktop BI tool Rebuild the BI worksheet around a Tableau-style layout: a data pane of dimensions and measures, filters and marks cards, rows and columns pill shelves above the view, a "Show me" chart picker, and a toolbar with swap, quick sort, and auto-update. Co-Authored-By: Claude Opus 5.5 --- .../data-warehouse/editor/QueryWindow.tsx | 8 +- .../data-warehouse/editor/SQLEditor.tsx | 4 +- .../data-warehouse/editor/bi/BIEditor.tsx | 765 ++---------------- .../data-warehouse/editor/bi/biEditorLogic.ts | 230 ++++-- .../editor/bi/biEditorOptions.tsx | 69 ++ .../editor/bi/biEditorTypes.test.ts | 31 + .../data-warehouse/editor/bi/biEditorTypes.ts | 175 +++- .../editor/bi/components/BIDataPane.tsx | 214 +++++ .../bi/components/BIExpressionPopover.tsx | 55 ++ .../editor/bi/components/BIFieldPill.tsx | 163 ++++ .../editor/bi/components/BIFilterEditor.tsx | 148 ++++ .../editor/bi/components/BIFilterPill.tsx | 64 ++ .../editor/bi/components/BIFiltersCard.tsx | 22 + .../editor/bi/components/BIMarksCard.tsx | 36 + .../editor/bi/components/BIPill.tsx | 61 ++ .../editor/bi/components/BIShelfCard.tsx | 11 + .../bi/components/BIShelfDropTarget.tsx | 86 ++ .../editor/bi/components/BIShelfStrip.tsx | 50 ++ .../editor/bi/components/BIShowMe.tsx | 78 ++ .../editor/bi/components/BIToolbar.tsx | 139 ++++ .../editor/editorSizingLogic.tsx | 17 +- 21 files changed, 1662 insertions(+), 764 deletions(-) create mode 100644 frontend/src/scenes/data-warehouse/editor/bi/biEditorOptions.tsx create mode 100644 frontend/src/scenes/data-warehouse/editor/bi/components/BIDataPane.tsx create mode 100644 frontend/src/scenes/data-warehouse/editor/bi/components/BIExpressionPopover.tsx create mode 100644 frontend/src/scenes/data-warehouse/editor/bi/components/BIFieldPill.tsx create mode 100644 frontend/src/scenes/data-warehouse/editor/bi/components/BIFilterEditor.tsx create mode 100644 frontend/src/scenes/data-warehouse/editor/bi/components/BIFilterPill.tsx create mode 100644 frontend/src/scenes/data-warehouse/editor/bi/components/BIFiltersCard.tsx create mode 100644 frontend/src/scenes/data-warehouse/editor/bi/components/BIMarksCard.tsx create mode 100644 frontend/src/scenes/data-warehouse/editor/bi/components/BIPill.tsx create mode 100644 frontend/src/scenes/data-warehouse/editor/bi/components/BIShelfCard.tsx create mode 100644 frontend/src/scenes/data-warehouse/editor/bi/components/BIShelfDropTarget.tsx create mode 100644 frontend/src/scenes/data-warehouse/editor/bi/components/BIShelfStrip.tsx create mode 100644 frontend/src/scenes/data-warehouse/editor/bi/components/BIShowMe.tsx create mode 100644 frontend/src/scenes/data-warehouse/editor/bi/components/BIToolbar.tsx diff --git a/frontend/src/scenes/data-warehouse/editor/QueryWindow.tsx b/frontend/src/scenes/data-warehouse/editor/QueryWindow.tsx index c559addf6c7a..8857994ea33c 100644 --- a/frontend/src/scenes/data-warehouse/editor/QueryWindow.tsx +++ b/frontend/src/scenes/data-warehouse/editor/QueryWindow.tsx @@ -352,7 +352,11 @@ export function QueryWindow({ ) : null} - {showQueryPanel && showBIEditor ? : null} + {showQueryPanel && showBIEditor ? ( + + {showOutputPanel ? : null} + + ) : null} {showQueryPanel && !showBIEditor ? ( ) : null} - {showOutputPanel ? ( + {showOutputPanel && !(showQueryPanel && showBIEditor) ? ( ) : null} diff --git a/frontend/src/scenes/data-warehouse/editor/SQLEditor.tsx b/frontend/src/scenes/data-warehouse/editor/SQLEditor.tsx index be8a73f269b4..ea78a47a887a 100644 --- a/frontend/src/scenes/data-warehouse/editor/SQLEditor.tsx +++ b/frontend/src/scenes/data-warehouse/editor/SQLEditor.tsx @@ -144,8 +144,8 @@ export function SQLEditor({ queryPaneMinHeight, biEditorResizerProps: { containerRef: biEditorRef, - logicKey: 'bi-editor-pane', - placement: 'bottom' as const, + logicKey: 'bi-editor-side-pane', + placement: 'right' as const, persistent: true, persistPrefix: 'v1', }, diff --git a/frontend/src/scenes/data-warehouse/editor/bi/BIEditor.tsx b/frontend/src/scenes/data-warehouse/editor/bi/BIEditor.tsx index 582ff1e12b98..712faeb1e961 100644 --- a/frontend/src/scenes/data-warehouse/editor/bi/BIEditor.tsx +++ b/frontend/src/scenes/data-warehouse/editor/bi/BIEditor.tsx @@ -1,713 +1,82 @@ -import { useActions, useValues } from 'kea' -import type { DragEvent, ReactNode } from 'react' +import { BindLogic, useValues } from 'kea' +import type { ReactNode } from 'react' -import { - IconCalculator, - IconDatabase, - IconFilter, - IconGraph, - IconLifecycle, - IconMagicWand, - IconPencil, - IconPieChart, - IconPlus, - IconTrends, - IconX, -} from '@posthog/icons' -import { LemonButton, LemonCard, LemonInput, LemonLabel, LemonSearchableSelect, LemonSelect } from '@posthog/lemon-ui' - -import { HogQLDropdown } from 'lib/components/HogQLDropdown/HogQLDropdown' import { Resizer } from 'lib/components/Resizer/Resizer' -import { FEATURE_FLAGS } from 'lib/constants' -import { dayjs } from 'lib/dayjs' -import { Icon123, IconAreaChart, IconDonutChart, IconHeatmap, IconTableChart } from 'lib/lemon-ui/icons' -import { LemonCalendarSelectInput } from 'lib/lemon-ui/LemonCalendar/LemonCalendarSelect' -import { featureFlagLogic } from 'lib/logic/featureFlagLogic' -import { cn } from 'lib/utils/css-classes' - -import { ChartDisplayType } from '~/types' +import { IconTableChart } from 'lib/lemon-ui/icons' import { editorSizingLogic } from '../editorSizingLogic' -import { queryDatabaseLogic } from '../sidebar/queryDatabaseLogic' import { biEditorLogic } from './biEditorLogic' -import { - BIAggregation, - BIDateBucket, - BI_FIELD_DRAG_MIME_TYPE, - BI_QUERY_LIMITS, - BIFilterOperator, - BIShelf, - BIField, - BISortDirection, - getBIDataSourceKey, - isDateTimeBIField, - isNumericBIField, - parseBIField, -} from './biEditorTypes' - -const CHART_TYPE_OPTIONS: { value: ChartDisplayType; label: string; icon: JSX.Element }[] = [ - { value: ChartDisplayType.Auto, label: 'Auto', icon: }, - { value: ChartDisplayType.ActionsTable, label: 'Table', icon: }, - { value: ChartDisplayType.ActionsLineGraph, label: 'Line chart', icon: }, - { value: ChartDisplayType.ActionsBar, label: 'Bar chart', icon: }, - { value: ChartDisplayType.ActionsStackedBar, label: 'Stacked bar chart', icon: }, - { value: ChartDisplayType.ActionsAreaGraph, label: 'Area chart', icon: }, - { value: ChartDisplayType.ActionsPie, label: 'Pie chart', icon: }, - { value: ChartDisplayType.ActionsDonut, label: 'Donut chart', icon: }, - { value: ChartDisplayType.TwoDimensionalHeatmap, label: 'Pivot table', icon: }, - { value: ChartDisplayType.BoldNumber, label: 'Big number', icon: }, - { value: ChartDisplayType.Metric, label: 'Metric', icon: }, -] - -const AGGREGATION_OPTIONS: { value: BIAggregation; label: string }[] = [ - { value: 'custom', label: 'SQL expression' }, - { value: 'count', label: 'Count' }, - { value: 'count_distinct', label: 'Count distinct' }, - { value: 'sum', label: 'Sum' }, - { value: 'average', label: 'Average' }, - { value: 'minimum', label: 'Minimum' }, - { value: 'maximum', label: 'Maximum' }, -] - -const FILTER_OPERATOR_OPTIONS: { value: BIFilterOperator; label: string }[] = [ - { value: 'equals', label: 'Equals' }, - { value: 'not_equals', label: 'Does not equal' }, - { value: 'contains', label: 'Contains' }, - { value: 'greater_than', label: 'Greater than' }, - { value: 'less_than', label: 'Less than' }, - { value: 'last_7_days', label: 'Last 7 days' }, - { value: 'is_set', label: 'Is set' }, - { value: 'is_not_set', label: 'Is not set' }, - { value: 'custom', label: 'SQL condition' }, -] - -const DATE_BUCKET_OPTIONS: { value: BIDateBucket | null; label: string }[] = [ - { value: null, label: 'Exact' }, - { value: 'minute', label: 'Minute' }, - { value: 'hour', label: 'Hour' }, - { value: 'day', label: 'Day' }, - { value: 'week', label: 'Week' }, - { value: 'month', label: 'Month' }, - { value: 'quarter', label: 'Quarter' }, - { value: 'year', label: 'Year' }, -] - -const LIMIT_OPTIONS = BI_QUERY_LIMITS.map((limit) => ({ - value: limit, - label: limit === 1000 ? '1k' : limit === 10000 ? '10k' : limit === 50000 ? '50k' : String(limit), -})) - -const SORT_DIRECTION_OPTIONS: { value: BISortDirection; label: string }[] = [ - { value: 'desc', label: 'Descending' }, - { value: 'asc', label: 'Ascending' }, -] - -export function BIEditor({ tabId }: { tabId: string }): JSX.Element { - const logic = biEditorLogic({ tabId }) - const { activeDropShelf, activeExpressionEditorId, availableDataSources, config, databaseLoading, sortOptions } = - useValues(logic) - const { biEditorHeight, biEditorResizerProps } = useValues(editorSizingLogic) - const { featureFlags } = useValues(featureFlagLogic) - const { setDatabaseTreeCollapsed } = useActions(editorSizingLogic) - const { locateTable } = useActions(queryDatabaseLogic) - const { - addBlankFieldToShelf, - addFieldToShelf, - clearActiveDropShelf, - removeFieldFromShelf, - resetConfig, - setActiveDropShelf, - setActiveExpressionEditorId, - setChartType, - setDataSource, - setFieldDateBucket, - setFieldExpression, - setFilterCustomExpression, - setFilterOperator, - setFilterValue, - setLimit, - setSort, - setValueAggregation, - setValueCustomExpression, - } = useActions(logic) +import { BIDataPane } from './components/BIDataPane' +import { BIFieldPill } from './components/BIFieldPill' +import { BIFiltersCard } from './components/BIFiltersCard' +import { BIMarksCard } from './components/BIMarksCard' +import { BIShelfStrip } from './components/BIShelfStrip' +import { BIShowMe } from './components/BIShowMe' +import { BIToolbar } from './components/BIToolbar' + +/** + * A worksheet laid out like desktop BI tools: data pane, filter and marks cards, rows and columns + * shelves above the view, and a chart picker on the right. + */ +export function BIEditor({ tabId, children }: { tabId: string; children: ReactNode }): JSX.Element { + const { config, showMeOpen } = useValues(biEditorLogic({ tabId })) + const { biSidePaneWidth, biEditorResizerProps } = useValues(editorSizingLogic) return ( -
-
-
-
- - Data source - - { - if (config.source) { - setDatabaseTreeCollapsed(false) - locateTable(config.source.table) - } - }} - > - Locate - + +
+ +
+
+
+ +
+
+ + +
+
-
- ({ - value: getBIDataSourceKey(source), - label: source.table, - }))} - onSelect={(sourceKey) => { - const source = availableDataSources.find( - (candidate) => getBIDataSourceKey(candidate) === sourceKey - ) - if (source) { - setDataSource(source) - } - }} - icon={} - loading={databaseLoading} - disabledReason={ - !databaseLoading && availableDataSources.length === 0 - ? 'No tables available for this connection' - : undefined - } - placeholder="Select a table" - searchPlaceholder="Search tables" - searchInputDataAttr="bi-editor-data-source-search" - noResultsMessage="No matching tables" - size="small" - className="min-w-64 max-w-120" - truncateText={{ maxWidthClass: 'max-w-96' }} - dropdownMaxContentWidth - data-attr="bi-editor-data-source" - /> - `Limit: ${option?.label ?? config.limit}`} - aria-label="Query row limit" - size="small" - dropdownMatchSelectWidth={false} - data-attr="bi-editor-query-limit" - /> - ({ value: option.key, label: option.label })), - ]} - onChange={(key) => - setSort(key === null ? null : { key, direction: config.sort?.direction ?? 'desc' }) - } - renderButtonContent={(option) => `Sort: ${option?.label ?? 'Auto'}`} - aria-label="Sort results by" - size="small" - dropdownMatchSelectWidth={false} - disabledReason={ - sortOptions.length === 0 ? 'Add a field to rows or columns first' : undefined - } - data-attr="bi-editor-sort" - /> - {config.sort ? ( - config.sort && setSort({ key: config.sort.key, direction })} - aria-label="Sort direction" - size="small" - dropdownMatchSelectWidth={false} - data-attr="bi-editor-sort-direction" - /> - ) : null} - + } + emptyText="Drop dimensions here for a second grouping" > - Clear - -
-
-
- Chart type -
- {CHART_TYPE_OPTIONS.filter( - (option) => - option.value !== ChartDisplayType.Metric || !!featureFlags[FEATURE_FLAGS.METRIC_INSIGHT] - ).map((option) => ( - setChartType(option.value)} - /> - ))} -
-
-
- -
- } - itemCount={config.rows.length} - isActiveDropTarget={activeDropShelf === 'rows'} - onDropField={addFieldToShelf} - onActiveDropTargetChange={(active) => - active ? setActiveDropShelf('rows') : clearActiveDropShelf('rows') - } - onAddField={() => addBlankFieldToShelf('rows')} - addFieldDisabledReason={!config.source ? 'Select a data source first' : undefined} - > - {config.rows.map((field, index) => ( - setActiveExpressionEditorId(null)} - onChange={(expression) => setFieldExpression('rows', index, expression)} - onDateBucketChange={(dateBucket) => setFieldDateBucket('rows', index, dateBucket)} - onRemove={() => removeFieldFromShelf('rows', index)} - /> - ))} - - - } - itemCount={config.columns.length} - isActiveDropTarget={activeDropShelf === 'columns'} - onDropField={addFieldToShelf} - onActiveDropTargetChange={(active) => - active ? setActiveDropShelf('columns') : clearActiveDropShelf('columns') - } - onAddField={() => addBlankFieldToShelf('columns')} - addFieldDisabledReason={!config.source ? 'Select a data source first' : undefined} - > - {config.columns.map((field, index) => ( - setActiveExpressionEditorId(null)} - onChange={(expression) => setFieldExpression('columns', index, expression)} - onDateBucketChange={(dateBucket) => setFieldDateBucket('columns', index, dateBucket)} - onRemove={() => removeFieldFromShelf('columns', index)} - /> - ))} - - - } - itemCount={config.values.length} - isActiveDropTarget={activeDropShelf === 'values'} - onDropField={addFieldToShelf} - onActiveDropTargetChange={(active) => - active ? setActiveDropShelf('values') : clearActiveDropShelf('values') - } - onAddField={() => addBlankFieldToShelf('values')} - addFieldDisabledReason={!config.source ? 'Select a data source first' : undefined} - > - {config.values.map((value, index) => ( -
( + + ))} + + } + emptyText="Drop dimensions or measures here" > - ({ - ...option, - disabledReason: - !isNumericBIField(value.field) && - ['sum', 'average', 'minimum', 'maximum'].includes(option.value) - ? 'This calculation requires a numeric field' - : undefined, - }))} - onChange={(aggregation) => setValueAggregation(index, aggregation)} - size="xsmall" - dropdownMatchSelectWidth={false} - /> - { - if (!visible) { - setActiveExpressionEditorId(null) - } - }} - onChange={(expression) => setFieldExpression('values', index, expression)} - /> - setFieldDateBucket('values', index, dateBucket)} - /> - {value.aggregation === 'custom' ? ( - setValueCustomExpression(index, customExpression)} - /> - ) : null} - removeFieldFromShelf('values', index)} - /> -
- ))} -
- - } - itemCount={config.filters.length} - isActiveDropTarget={activeDropShelf === 'filters'} - onDropField={addFieldToShelf} - onActiveDropTargetChange={(active) => - active ? setActiveDropShelf('filters') : clearActiveDropShelf('filters') - } - onAddField={() => addBlankFieldToShelf('filters')} - addFieldDisabledReason={!config.source ? 'Select a data source first' : undefined} - > - {config.filters.map((filter, index) => { - const filterNeedsValue = !['last_7_days', 'is_set', 'is_not_set', 'custom'].includes( - filter.operator - ) - return ( -
- { - if (!visible) { - setActiveExpressionEditorId(null) - } - }} - onChange={(expression) => setFieldExpression('filters', index, expression)} - /> - setFieldDateBucket('filters', index, dateBucket)} - /> - ({ - ...option, - disabledReason: - option.value === 'last_7_days' && !isDateTimeBIField(filter.field) - ? 'Choose a date or date-time field' - : undefined, - }))} - onChange={(operator) => setFilterOperator(index, operator)} - size="xsmall" - dropdownMatchSelectWidth={false} - /> - {filter.operator === 'custom' ? ( - - setFilterCustomExpression(index, customExpression) - } - /> - ) : filterNeedsValue ? ( - isDateTimeBIField(filter.field) ? ( - setFilterValue(index, value)} - /> - ) : ( - setFilterValue(index, value)} - placeholder="Value" - aria-label={`${filter.field.name} filter value`} - size="small" - /> - ) - ) : null} - removeFieldFromShelf('filters', index)} - /> -
- ) - })} -
-
- -
- ) -} - -function FieldExpressionEditor({ - field, - autoOpen, - onAutoOpenClose, - onChange, - onDateBucketChange, - onRemove, -}: { - field: BIField - autoOpen: boolean - onAutoOpenClose: () => void - onChange: (expression: string) => void - onDateBucketChange: (dateBucket: BIDateBucket | null) => void - onRemove: () => void -}): JSX.Element { - return ( -
- { - if (!visible) { - onAutoOpenClose() - } - }} - onChange={onChange} - /> - - -
- ) -} - -function DateBucketSelect({ - field, - onChange, -}: { - field: BIField - onChange: (dateBucket: BIDateBucket | null) => void -}): JSX.Element | null { - if (!isDateTimeBIField(field)) { - return null - } - - return ( - - ) -} - -function RemoveFieldButton({ field, onClick }: { field: BIField; onClick: () => void }): JSX.Element { - const fieldName = field.name || 'field' - - return ( - } - size="xsmall" - type="tertiary" - noPadding - className="shrink-0" - tooltip={`Remove ${fieldName}`} - aria-label={`Remove ${fieldName}`} - onClick={onClick} - /> - ) -} - -function ExpressionEditorButton({ - value, - field, - label, - emptyLabel, - visible, - onVisibilityChange, - onChange, -}: { - value: string - field: BIField - label: string - emptyLabel?: string - visible?: boolean - onVisibilityChange?: (visible: boolean) => void - onChange: (value: string) => void -}): JSX.Element { - return ( - } - buttonLabel={value ? {value} : emptyLabel} - buttonTooltip={label} - buttonAriaLabel={`${label} for ${field.name || 'field'}`} - visible={visible} - onVisibilityChange={onVisibilityChange} - /> - ) -} - -function DateTimeFilterInput({ - field, - value, - onChange, -}: { - field: BIField - value: string - onChange: (value: string) => void -}): JSX.Element { - const selectedDate = value && dayjs(value).isValid() ? dayjs(value) : null - const includesTime = field.type === 'datetime' - - return ( - onChange(date?.format(includesTime ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD') ?? '')} - granularity={includesTime ? 'minute' : 'day'} - format={includesTime ? 'MMM D, YYYY HH:mm' : 'MMM D, YYYY'} - use24HourFormat - clearable - placeholder={includesTime ? 'Select date and time' : 'Select date'} - buttonProps={{ size: 'small', 'aria-label': `${field.name} filter date` }} - /> - ) -} - -function Shelf({ - shelf, - title, - description, - icon, - itemCount, - isActiveDropTarget, - children, - onDropField, - onActiveDropTargetChange, - onAddField, - addFieldDisabledReason, -}: { - shelf: BIShelf - title: string - description: string - icon: ReactNode - itemCount: number - isActiveDropTarget: boolean - children: ReactNode - onDropField: (field: NonNullable>, shelf: BIShelf) => void - onActiveDropTargetChange: (active: boolean) => void - onAddField: () => void - addFieldDisabledReason?: string -}): JSX.Element { - const handleDrop = (event: DragEvent): void => { - event.preventDefault() - onActiveDropTargetChange(false) - const field = parseBIField(event.dataTransfer.getData(BI_FIELD_DRAG_MIME_TYPE)) - if (field) { - onDropField(field, shelf) - } - } - - return ( - -
{ - if (Array.from(event.dataTransfer.types).includes(BI_FIELD_DRAG_MIME_TYPE)) { - onActiveDropTargetChange(true) - } - }} - onDragOver={(event) => { - if (Array.from(event.dataTransfer.types).includes(BI_FIELD_DRAG_MIME_TYPE)) { - event.preventDefault() - event.dataTransfer.dropEffect = 'copy' - } - }} - onDragLeave={(event) => { - const nextTarget = event.relatedTarget - if (!nextTarget || !event.currentTarget.contains(nextTarget as Node)) { - onActiveDropTargetChange(false) - } - }} - onDrop={handleDrop} - > -
-
- {icon} - {title} ({itemCount}) + {[ + ...config.rows.map((field, index) => ( + + )), + ...config.values.map((value, index) => ( + + )), + ]} + +
{children}
- } - size="xsmall" - type="tertiary" - noPadding - tooltip="Add field" - aria-label={`Add field to ${title.toLowerCase()}`} - disabledReason={addFieldDisabledReason} - onClick={onAddField} - data-attr={`bi-editor-${shelf}-add-field`} - /> + {showMeOpen ? ( +
+ +
+ ) : null}
- {description} -
{children}
-
+ ) } diff --git a/frontend/src/scenes/data-warehouse/editor/bi/biEditorLogic.ts b/frontend/src/scenes/data-warehouse/editor/bi/biEditorLogic.ts index 92865abf1703..822c8c4c685f 100644 --- a/frontend/src/scenes/data-warehouse/editor/bi/biEditorLogic.ts +++ b/frontend/src/scenes/data-warehouse/editor/bi/biEditorLogic.ts @@ -1,7 +1,8 @@ import { MakeLogicType, actions, afterMount, connect, kea, key, listeners, path, props, reducers, selectors } from 'kea' +import { subscriptions } from 'kea-subscriptions' import { uuid } from 'lib/utils/dom' -import { databaseTableListLogic } from 'scenes/data-management/database/databaseTableListLogic' +import { TableFieldsStatus, databaseTableListLogic } from 'scenes/data-management/database/databaseTableListLogic' import { DatabaseSchemaTable } from '~/queries/schema/schema-general' import { ChartDisplayType } from '~/types' @@ -11,7 +12,9 @@ import type { QueryTab } from '../sqlEditorLogic' import { captureBIEditorModeSelected } from './biEditorAnalytics' import { BIAggregation, + BIChartFit, BIConfig, + BIDataPaneFields, BIDataSource, BIDateBucket, BIEditorState, @@ -27,6 +30,8 @@ import { buildBIQuery, createDefaultDateFilter, defaultAggregationForField, + getBIChartFit, + getBIDataPaneFields, getBISortOptions, isBIFieldCompatible, normalizeBIConfig, @@ -118,6 +123,27 @@ function addFieldToConfig(config: BIConfig, field: BIField, shelf: BIShelf): BIC } } +function fieldOnShelf(config: BIConfig, shelf: BIShelf, index: number): BIField | null { + switch (shelf) { + case 'rows': + case 'columns': + return config[shelf][index] ?? null + case 'values': + return config.values[index]?.field ?? null + case 'filters': + return config.filters[index]?.field ?? null + } +} + +function moveFieldInConfig(config: BIConfig, fromShelf: BIShelf, fromIndex: number, toShelf: BIShelf): BIConfig { + const field = fieldOnShelf(config, fromShelf, fromIndex) + if (!field || fromShelf === toShelf) { + return config + } + + return addFieldToConfig(removeFieldFromConfig(config, fromShelf, fromIndex), field, toShelf) +} + function removeFieldFromConfig(config: BIConfig, shelf: BIShelf, index: number): BIConfig { switch (shelf) { case 'rows': @@ -195,18 +221,29 @@ export interface biEditorLogicValues { databaseConnectionId: string | null // databaseTableListLogic databaseLoading: boolean // databaseTableListLogic posthogTables: DatabaseSchemaTable[] // databaseTableListLogic + tableFieldsStatus: TableFieldsStatus // databaseTableListLogic activeTab: QueryTab | null // sqlEditorLogic activeDropShelf: BIShelf | null activeExpressionEditorId: string | null + autoUpdate: boolean availableDataSources: BIDataSource[] + chartFits: Partial> config: BIConfig + dataPaneFields: BIDataPaneFields + dataPaneFieldsLoading: boolean + dataPaneSearch: string editorView: BIEditorView + filteredDataPaneFields: BIDataPaneFields generatedQuery: BIQueryBuildResult | null + showMeOpen: boolean sortOptions: BISortOption[] } // Generated by kea-typegen. Update if you're an agent, ignore if you're human. export interface biEditorLogicActions { + hydrateTableFields: (tableNames: string[]) => { + tableNames: string[] + } // databaseTableListLogic setSourceQuery: (sourceQuery: import('~/queries/schema/schema-general').DataVisualizationNode) => { sourceQuery: import('~/queries/schema/schema-general').DataVisualizationNode } // sqlEditorLogic @@ -230,6 +267,15 @@ export interface biEditorLogicActions { clearActiveDropShelf: (shelf: BIShelf) => { shelf: BIShelf } + moveFieldToShelf: ( + fromShelf: BIShelf, + fromIndex: number, + toShelf: BIShelf + ) => { + fromIndex: number + fromShelf: BIShelf + toShelf: BIShelf + } persistState: ( editorView: BIEditorView, config: BIConfig @@ -250,15 +296,24 @@ export interface biEditorLogicActions { restoreState: (state: BIEditorState) => { state: BIEditorState } + runAfterChange: () => { + value: true + } setActiveDropShelf: (shelf: BIShelf) => { shelf: BIShelf } setActiveExpressionEditorId: (fieldId: string | null) => { fieldId: string | null } + setAutoUpdate: (autoUpdate: boolean) => { + autoUpdate: boolean + } setChartType: (chartType: ChartDisplayType) => { chartType: ChartDisplayType } + setDataPaneSearch: (search: string) => { + search: string + } setDataSource: (source: BIDataSource) => { source: BIDataSource } @@ -307,6 +362,9 @@ export interface biEditorLogicActions { setLimit: (limit: BIQueryLimit) => { limit: 100 | 1000 | 10000 | 50000 } + setShowMeOpen: (showMeOpen: boolean) => { + showMeOpen: boolean + } setSort: (sort: BISort | null) => { sort: BISort | null } @@ -324,6 +382,9 @@ export interface biEditorLogicActions { customExpression: string index: number } + swapRowsAndColumns: () => { + value: true + } syncGeneratedQuery: () => { value: true } @@ -338,6 +399,10 @@ export interface biEditorLogicMeta { posthogTables: DatabaseSchemaTable[], databaseConnectionId: string | null ) => BIDataSource[] + chartFits: (config: BIConfig) => Partial> + dataPaneFields: (config: BIConfig, allTables: DatabaseSchemaTable[]) => BIDataPaneFields + dataPaneFieldsLoading: (config: BIConfig, tableFieldsStatus: TableFieldsStatus) => boolean + filteredDataPaneFields: (dataPaneFields: BIDataPaneFields, dataPaneSearch: string) => BIDataPaneFields generatedQuery: (config: BIConfig) => BIQueryBuildResult | null sortOptions: (config: BIConfig) => BISortOption[] } @@ -359,9 +424,20 @@ export const biEditorLogic = kea([ sqlEditorLogic({ tabId: logicProps.tabId }), ['activeTab'], databaseTableListLogic, - ['allTables', 'posthogTables', 'connectionId as databaseConnectionId', 'databaseLoading'], + [ + 'allTables', + 'posthogTables', + 'connectionId as databaseConnectionId', + 'databaseLoading', + 'tableFieldsStatus', + ], + ], + actions: [ + sqlEditorLogic({ tabId: logicProps.tabId }), + ['setSourceQuery', 'syncUrlWithQuery', 'updateTab'], + databaseTableListLogic, + ['hydrateTableFields'], ], - actions: [sqlEditorLogic({ tabId: logicProps.tabId }), ['setSourceQuery', 'syncUrlWithQuery', 'updateTab']], })), actions({ setEditorView: (editorView: BIEditorView) => ({ editorView }), @@ -370,6 +446,16 @@ export const biEditorLogic = kea([ addFieldToShelf: (field: BIField, shelf: BIShelf) => ({ field, shelf }), addBlankFieldToShelf: (shelf: BIShelf) => ({ shelf, fieldId: `bi-blank-${uuid()}` }), clearActiveDropShelf: (shelf: BIShelf) => ({ shelf }), + moveFieldToShelf: (fromShelf: BIShelf, fromIndex: number, toShelf: BIShelf) => ({ + fromShelf, + fromIndex, + toShelf, + }), + swapRowsAndColumns: true, + setAutoUpdate: (autoUpdate: boolean) => ({ autoUpdate }), + setShowMeOpen: (showMeOpen: boolean) => ({ showMeOpen }), + setDataPaneSearch: (search: string) => ({ search }), + runAfterChange: true, removeFieldFromShelf: (shelf: BIShelf, index: number) => ({ shelf, index }), setActiveDropShelf: (shelf: BIShelf) => ({ shelf }), setActiveExpressionEditorId: (fieldId: string | null) => ({ fieldId }), @@ -400,10 +486,22 @@ export const biEditorLogic = kea([ activeDropShelf === shelf ? null : activeDropShelf, }, ], + autoUpdate: [true, { persist: true }, { setAutoUpdate: (_, { autoUpdate }) => autoUpdate }], + showMeOpen: [true, { persist: true }, { setShowMeOpen: (_, { showMeOpen }) => showMeOpen }], + dataPaneSearch: [ + '', + { + setDataPaneSearch: (_, { search }) => search, + setDataSource: () => '', + }, + ], activeExpressionEditorId: [ null as string | null, { addBlankFieldToShelf: (_, { fieldId }) => fieldId, + // Opens the filter editor as soon as a field lands on the filters shelf + addFieldToShelf: (activeExpressionEditorId, { field, shelf }) => + shelf === 'filters' ? field.id : activeExpressionEditorId, setActiveExpressionEditorId: (_, { fieldId }) => fieldId, resetConfig: () => null, restoreState: () => null, @@ -427,6 +525,10 @@ export const biEditorLogic = kea([ addBlankFieldToShelf: (config, { shelf, fieldId }) => config.source ? addFieldToConfig(config, blankField(config.source, fieldId), shelf) : config, removeFieldFromShelf: (config, { shelf, index }) => removeFieldFromConfig(config, shelf, index), + moveFieldToShelf: (config, { fromShelf, fromIndex, toShelf }) => + normalizeBIConfig(moveFieldInConfig(config, fromShelf, fromIndex, toShelf)), + swapRowsAndColumns: (config) => + normalizeBIConfig({ ...config, rows: config.columns, columns: config.rows }), setChartType: (config, { chartType }) => normalizeBIConfig({ ...config, chartType }), setDataSource: (config, { source }) => setDataSourceInConfig(config, source), setValueAggregation: (config, { index, aggregation }) => ({ @@ -499,6 +601,42 @@ export const biEditorLogic = kea([ (selectors) => [selectors.config], (config: BIConfig): BISortOption[] => getBISortOptions(config), ], + chartFits: [ + (selectors) => [selectors.config], + (config: BIConfig): Partial> => + Object.fromEntries( + Object.values(ChartDisplayType).map((chartType) => [chartType, getBIChartFit(config, chartType)]) + ), + ], + dataPaneFields: [ + (selectors) => [selectors.config, selectors.allTables], + (config: BIConfig, allTables: DatabaseSchemaTable[]): BIDataPaneFields => + config.source + ? getBIDataPaneFields( + allTables.find((table) => table.name === config.source?.table), + config.source + ) + : { dimensions: [], measures: [] }, + ], + dataPaneFieldsLoading: [ + (selectors) => [selectors.config, selectors.tableFieldsStatus], + (config: BIConfig, tableFieldsStatus: TableFieldsStatus): boolean => + !!config.source && tableFieldsStatus[config.source.table] === 'loading', + ], + filteredDataPaneFields: [ + (selectors) => [selectors.dataPaneFields, selectors.dataPaneSearch], + (dataPaneFields: BIDataPaneFields, dataPaneSearch: string): BIDataPaneFields => { + const needle = dataPaneSearch.trim().toLowerCase() + if (!needle) { + return dataPaneFields + } + const matches = (field: BIField): boolean => field.name.toLowerCase().includes(needle) + return { + dimensions: dataPaneFields.dimensions.filter(matches), + measures: dataPaneFields.measures.filter(matches), + } + }, + ], }), listeners(({ actions, props: logicProps, values }) => ({ persistState: ({ editorView, config }) => { @@ -518,62 +656,37 @@ export const biEditorLogic = kea([ actions.syncGeneratedQuery() } }, - addFieldToShelf: () => { - actions.persistState(values.editorView, values.config) - actions.syncGeneratedQuery() - }, - addBlankFieldToShelf: () => { - actions.persistState(values.editorView, values.config) - actions.syncGeneratedQuery() - }, - removeFieldFromShelf: () => { - actions.persistState(values.editorView, values.config) - actions.syncGeneratedQuery() - }, - setChartType: () => { - actions.persistState(values.editorView, values.config) - actions.syncGeneratedQuery() - }, - setDataSource: () => { - actions.persistState(values.editorView, values.config) - actions.syncGeneratedQuery() - }, - setValueAggregation: () => { - actions.persistState(values.editorView, values.config) - actions.syncGeneratedQuery() - }, - setFilterOperator: () => { - actions.persistState(values.editorView, values.config) - actions.syncGeneratedQuery() - }, - setFilterValue: () => { - actions.persistState(values.editorView, values.config) - actions.syncGeneratedQuery() - }, - setLimit: () => { - actions.persistState(values.editorView, values.config) - actions.syncGeneratedQuery() - }, - setSort: () => { - actions.persistState(values.editorView, values.config) - actions.syncGeneratedQuery() - }, - setFieldExpression: () => { - actions.persistState(values.editorView, values.config) - actions.syncGeneratedQuery() - }, - setFieldDateBucket: () => { - actions.persistState(values.editorView, values.config) - actions.syncGeneratedQuery() - }, - setValueCustomExpression: () => { - actions.persistState(values.editorView, values.config) - actions.syncGeneratedQuery() + addFieldToShelf: () => actions.runAfterChange(), + moveFieldToShelf: () => actions.runAfterChange(), + swapRowsAndColumns: () => actions.runAfterChange(), + setAutoUpdate: ({ autoUpdate }) => { + if (autoUpdate) { + actions.runAfterChange() + } }, - setFilterCustomExpression: () => { + runAfterChange: async (_, breakpoint) => { actions.persistState(values.editorView, values.config) actions.syncGeneratedQuery() + if (!values.autoUpdate || !values.generatedQuery) { + return + } + // Debounced so typing a filter value or clicking through menus runs one query + await breakpoint(400) + sqlEditorLogic({ tabId: logicProps.tabId }).actions.runQuery() }, + addBlankFieldToShelf: () => actions.runAfterChange(), + removeFieldFromShelf: () => actions.runAfterChange(), + setChartType: () => actions.runAfterChange(), + setDataSource: () => actions.runAfterChange(), + setValueAggregation: () => actions.runAfterChange(), + setFilterOperator: () => actions.runAfterChange(), + setFilterValue: () => actions.runAfterChange(), + setLimit: () => actions.runAfterChange(), + setSort: () => actions.runAfterChange(), + setFieldExpression: () => actions.runAfterChange(), + setFieldDateBucket: () => actions.runAfterChange(), + setValueCustomExpression: () => actions.runAfterChange(), + setFilterCustomExpression: () => actions.runAfterChange(), setSourceQuery: ({ sourceQuery }) => { if (!values.activeTab?.biEditorState && values.editorView === BIEditorView.SQL) { return @@ -623,6 +736,13 @@ export const biEditorLogic = kea([ }) }, })), + subscriptions(({ actions }) => ({ + config: (config: BIConfig, oldConfig: BIConfig | undefined) => { + if (config.source && config.source.table !== oldConfig?.source?.table) { + actions.hydrateTableFields([config.source.table]) + } + }, + })), afterMount(({ actions, values }) => { if (values.activeTab?.biEditorState) { actions.restoreState(values.activeTab.biEditorState) diff --git a/frontend/src/scenes/data-warehouse/editor/bi/biEditorOptions.tsx b/frontend/src/scenes/data-warehouse/editor/bi/biEditorOptions.tsx new file mode 100644 index 000000000000..4b1bf73e90fb --- /dev/null +++ b/frontend/src/scenes/data-warehouse/editor/bi/biEditorOptions.tsx @@ -0,0 +1,69 @@ +import { IconGraph, IconLifecycle, IconMagicWand, IconPieChart, IconPulse, IconTrends } from '@posthog/icons' + +import { FEATURE_FLAGS } from 'lib/constants' +import { Icon123, IconAreaChart, IconDonutChart, IconHeatmap, IconTableChart } from 'lib/lemon-ui/icons' +import { FeatureFlagsSet } from 'lib/logic/featureFlagLogic' + +import { ChartDisplayType } from '~/types' + +import { BIAggregation, BIDateBucket, BIFilterOperator, BI_QUERY_LIMITS } from './biEditorTypes' + +export const CHART_TYPE_OPTIONS: { value: ChartDisplayType; label: string; icon: JSX.Element }[] = [ + { value: ChartDisplayType.Auto, label: 'Automatic', icon: }, + { value: ChartDisplayType.ActionsTable, label: 'Table', icon: }, + { value: ChartDisplayType.TwoDimensionalHeatmap, label: 'Pivot table', icon: }, + { value: ChartDisplayType.BoldNumber, label: 'Big number', icon: }, + { value: ChartDisplayType.Metric, label: 'Metric', icon: }, + { value: ChartDisplayType.ActionsLineGraph, label: 'Line chart', icon: }, + { value: ChartDisplayType.ActionsAreaGraph, label: 'Area chart', icon: }, + { value: ChartDisplayType.ActionsBar, label: 'Bar chart', icon: }, + { value: ChartDisplayType.ActionsStackedBar, label: 'Stacked bar chart', icon: }, + { value: ChartDisplayType.ActionsPie, label: 'Pie chart', icon: }, + { value: ChartDisplayType.ActionsDonut, label: 'Donut chart', icon: }, +] + +export function getChartTypeOptions(featureFlags: FeatureFlagsSet): typeof CHART_TYPE_OPTIONS { + return CHART_TYPE_OPTIONS.filter( + (option) => option.value !== ChartDisplayType.Metric || !!featureFlags[FEATURE_FLAGS.METRIC_INSIGHT] + ) +} + +export const AGGREGATION_OPTIONS: { value: BIAggregation; label: string }[] = [ + { value: 'sum', label: 'Sum' }, + { value: 'average', label: 'Average' }, + { value: 'minimum', label: 'Minimum' }, + { value: 'maximum', label: 'Maximum' }, + { value: 'count', label: 'Count' }, + { value: 'count_distinct', label: 'Count distinct' }, + { value: 'custom', label: 'SQL expression' }, +] + +export const NUMERIC_AGGREGATIONS: BIAggregation[] = ['sum', 'average', 'minimum', 'maximum'] + +export const FILTER_OPERATOR_OPTIONS: { value: BIFilterOperator; label: string }[] = [ + { value: 'equals', label: 'Equals' }, + { value: 'not_equals', label: 'Does not equal' }, + { value: 'contains', label: 'Contains' }, + { value: 'greater_than', label: 'Greater than' }, + { value: 'less_than', label: 'Less than' }, + { value: 'last_7_days', label: 'Last 7 days' }, + { value: 'is_set', label: 'Is set' }, + { value: 'is_not_set', label: 'Is not set' }, + { value: 'custom', label: 'SQL condition' }, +] + +export const DATE_BUCKET_OPTIONS: { value: BIDateBucket | null; label: string }[] = [ + { value: null, label: 'Exact date' }, + { value: 'minute', label: 'Minute' }, + { value: 'hour', label: 'Hour' }, + { value: 'day', label: 'Day' }, + { value: 'week', label: 'Week' }, + { value: 'month', label: 'Month' }, + { value: 'quarter', label: 'Quarter' }, + { value: 'year', label: 'Year' }, +] + +export const LIMIT_OPTIONS = BI_QUERY_LIMITS.map((limit) => ({ + value: limit, + label: limit === 1000 ? '1k' : limit === 10000 ? '10k' : limit === 50000 ? '50k' : String(limit), +})) diff --git a/frontend/src/scenes/data-warehouse/editor/bi/biEditorTypes.test.ts b/frontend/src/scenes/data-warehouse/editor/bi/biEditorTypes.test.ts index b238d655486e..f663c7862590 100644 --- a/frontend/src/scenes/data-warehouse/editor/bi/biEditorTypes.test.ts +++ b/frontend/src/scenes/data-warehouse/editor/bi/biEditorTypes.test.ts @@ -9,8 +9,10 @@ import { createDefaultDateFilter, defaultAggregationForField, getBIDataSourceKey, + getBIDropTarget, getBIFieldId, getBISortOptions, + getBIValueSortKey, isBIFieldCompatible, parseBIEditorState, } from './biEditorTypes' @@ -388,6 +390,10 @@ describe('BI editor query generation', () => { `values:${revenueField.id}`, `values:${revenueField.id}:2`, ]) + expect([0, 1].map((index) => getBIValueSortKey(twoAggregationsConfig, index))).toEqual([ + `values:${revenueField.id}`, + `values:${revenueField.id}:2`, + ]) expect( buildBIQuery({ ...twoAggregationsConfig, @@ -396,6 +402,31 @@ describe('BI editor query generation', () => { ).toContain('ORDER BY\n average_revenue_2 ASC') }) + const userIdField: BIField = { ...revenueField, id: 'warehouse:events:user_id', name: 'user_id', type: 'integer' } + + test.each([ + ['a measure dropped on rows becomes a value', revenueField, 'rows', revenueField, 'values'], + ['a measure dropped on columns becomes a value', revenueField, 'columns', revenueField, 'values'], + ['a measure dropped on filters stays a filter', revenueField, 'filters', revenueField, 'filters'], + ['a numeric identifier stays a dimension', userIdField, 'rows', userIdField, 'rows'], + [ + 'a timestamp on rows is bucketed by day', + timestampField, + 'rows', + { ...timestampField, dateBucket: 'day' }, + 'rows', + ], + [ + 'a bucketed timestamp keeps its bucket', + { ...timestampField, dateBucket: 'month' }, + 'columns', + { ...timestampField, dateBucket: 'month' }, + 'columns', + ], + ] as const)('routes a dropped field: %s', (_, field, shelf, expectedField, expectedShelf) => { + expect(getBIDropTarget(field as BIField, shelf)).toEqual({ field: expectedField, shelf: expectedShelf }) + }) + test.each([ ['a state persisted before sort existed', {}, null], [ diff --git a/frontend/src/scenes/data-warehouse/editor/bi/biEditorTypes.ts b/frontend/src/scenes/data-warehouse/editor/bi/biEditorTypes.ts index a601e3f51c6d..f89e6dfee2cf 100644 --- a/frontend/src/scenes/data-warehouse/editor/bi/biEditorTypes.ts +++ b/frontend/src/scenes/data-warehouse/editor/bi/biEditorTypes.ts @@ -1,4 +1,9 @@ -import { DataVisualizationNode, DatabaseSerializedFieldType, NodeKind } from '~/queries/schema/schema-general' +import { + DataVisualizationNode, + DatabaseSchemaTable, + DatabaseSerializedFieldType, + NodeKind, +} from '~/queries/schema/schema-general' import { escapeDottedHogQLIdentifier, escapeHogQLString, escapePropertyAsHogQLIdentifier } from '~/queries/utils' import { ChartDisplayType } from '~/types' @@ -187,6 +192,138 @@ export function defaultAggregationForField(field: BIField): BIAggregation { return isNumericBIField(field) ? 'sum' : 'count_distinct' } +const IDENTIFIER_FIELD_NAME_REGEX = /(^|_)(id|uuid)$/i + +/** Numeric fields that are not identifiers aggregate by default, like measures in a BI tool. */ +export function isBIMeasureField(field: BIField): boolean { + return ( + isNumericBIField(field) && + defaultAggregationForField(field) !== 'count' && + !IDENTIFIER_FIELD_NAME_REGEX.test(field.name) + ) +} + +export const BI_SHELF_PILL_DRAG_MIME_TYPE = 'application/x-posthog-bi-shelf-pill' + +export interface BIShelfPillDragData { + shelf: BIShelf + index: number +} + +export function parseBIShelfPillDragData(serialized: string): BIShelfPillDragData | null { + try { + const candidate = JSON.parse(serialized) as Partial + if ( + ['rows', 'columns', 'values', 'filters'].includes(candidate.shelf as string) && + typeof candidate.index === 'number' + ) { + return { shelf: candidate.shelf as BIShelf, index: candidate.index } + } + } catch { + return null + } + return null +} + +/** + * Where a field dropped from the data pane lands. Measures dropped on rows or columns are aggregated + * rather than grouped, and exact timestamps are bucketed by day so grouping stays readable. + */ +export function getBIDropTarget(field: BIField, shelf: BIShelf): { field: BIField; shelf: BIShelf } { + if ((shelf === 'rows' || shelf === 'columns') && isBIMeasureField(field)) { + return { field, shelf: 'values' } + } + if ((shelf === 'rows' || shelf === 'columns') && field.type === 'datetime' && !field.dateBucket) { + return { field: { ...field, dateBucket: 'day' }, shelf } + } + return { field, shelf } +} + +const DATA_PANE_FIELD_TYPES = new Set([ + 'integer', + 'float', + 'decimal', + 'string', + 'datetime', + 'date', + 'boolean', + 'array', + 'json', + 'expression', + 'unknown', +]) + +export interface BIDataPaneFields { + dimensions: BIField[] + measures: BIField[] +} + +export function getBIDataPaneFields(table: DatabaseSchemaTable | undefined, source: BIDataSource): BIDataPaneFields { + const fields = Object.values(table?.fields ?? {}) + .filter((field) => DATA_PANE_FIELD_TYPES.has(field.type)) + .map((field): BIField => { + const expression = escapeDottedHogQLIdentifier(field.name) + return { id: getBIFieldId(source, expression), name: field.name, expression, type: field.type, source } + }) + .sort((first, second) => first.name.localeCompare(second.name)) + + return { + dimensions: fields.filter((field) => !isBIMeasureField(field)), + measures: fields.filter(isBIMeasureField), + } +} + +export interface BIChartFit { + fits: boolean + requirement: string +} + +/** Mirrors the "Show me" panel of desktop BI tools: which chart types suit the fields on the shelves. */ +export function getBIChartFit(config: BIConfig, chartType: ChartDisplayType): BIChartFit { + const rowCount = config.rows.length + const columnCount = config.columns.length + const dimensionCount = rowCount + columnCount + const hasDateDimension = [...config.rows, ...config.columns].some(isDateTimeBIField) + + switch (chartType) { + case ChartDisplayType.Auto: + return { fits: true, requirement: 'Picks a chart type from the query results' } + case ChartDisplayType.ActionsTable: + return { fits: true, requirement: 'any combination of fields' } + case ChartDisplayType.ActionsLineGraph: + case ChartDisplayType.ActionsAreaGraph: + return { + fits: hasDateDimension && dimensionCount <= 2, + requirement: '1 date, up to 1 more dimension, and any measures', + } + case ChartDisplayType.ActionsBar: + case ChartDisplayType.ActionsStackedBar: + return { + fits: dimensionCount >= 1 && dimensionCount <= 2, + requirement: '1 or 2 dimensions, and any measures', + } + case ChartDisplayType.ActionsPie: + case ChartDisplayType.ActionsDonut: + return { + fits: dimensionCount === 1 && config.values.length <= 1, + requirement: '1 dimension and up to 1 measure', + } + case ChartDisplayType.TwoDimensionalHeatmap: + return { + fits: rowCount >= 1 && columnCount >= 1, + requirement: '1 or more dimensions on rows and on columns', + } + case ChartDisplayType.BoldNumber: + case ChartDisplayType.Metric: + return { + fits: dimensionCount === 0 && config.values.length <= 1, + requirement: 'no dimensions and up to 1 measure', + } + default: + return { fits: true, requirement: '' } + } +} + export function createDefaultDateFilter(source: BIDataSource): BIFilter | null { if (source.connectionId) { return null @@ -588,6 +725,42 @@ export function getBISortOptions(config: BIConfig): BISortOption[] { }) } +/** The sort option key for the value at `index`, matching the keys `getBISortOptions` returns. */ +export function getBIValueSortKey(config: BIConfig, index: number): string | null { + const value = config.values[index] + if (!value || !aggregationExpression(value)) { + return null + } + const occurrence = config.values + .slice(0, index) + .filter((previous) => previous.field.id === value.field.id && !!aggregationExpression(previous)).length + return occurrence === 0 ? `values:${value.field.id}` : `values:${value.field.id}:${occurrence + 1}` +} + +const PILL_AGGREGATION_PREFIXES: Record, string> = { + count: 'COUNT', + count_distinct: 'COUNTD', + sum: 'SUM', + average: 'AVG', + minimum: 'MIN', + maximum: 'MAX', +} + +export function getBIFieldPillLabel(field: BIField): string { + const name = field.name || field.expression.trim() + if (!name) { + return 'New calculation' + } + return field.dateBucket ? `${field.dateBucket.toUpperCase()}(${name})` : name +} + +export function getBIValuePillLabel(value: BIValue): string { + if (value.aggregation === 'custom') { + return value.customExpression?.trim() || 'New calculation' + } + return `${PILL_AGGREGATION_PREFIXES[value.aggregation]}(${getBIFieldPillLabel(value.field)})` +} + function buildOrderByExpression( config: BIConfig, dimensions: BIDimension[], diff --git a/frontend/src/scenes/data-warehouse/editor/bi/components/BIDataPane.tsx b/frontend/src/scenes/data-warehouse/editor/bi/components/BIDataPane.tsx new file mode 100644 index 000000000000..0aa0a3a32a78 --- /dev/null +++ b/frontend/src/scenes/data-warehouse/editor/bi/components/BIDataPane.tsx @@ -0,0 +1,214 @@ +import { useActions, useValues } from 'kea' + +import { IconCalendar, IconDatabase } from '@posthog/icons' +import { LemonButton, LemonInput, LemonSearchableSelect, Spinner } from '@posthog/lemon-ui' + +import { cn } from 'lib/utils/css-classes' + +import { editorSizingLogic } from '../../editorSizingLogic' +import { queryDatabaseLogic } from '../../sidebar/queryDatabaseLogic' +import { biEditorLogic } from '../biEditorLogic' +import { + BIField, + BI_FIELD_DRAG_MIME_TYPE, + getBIDataSourceKey, + getBIDropTarget, + serializeBIField, +} from '../biEditorTypes' + +function FieldTypeGlyph({ field, measure }: { field: BIField; measure: boolean }): JSX.Element { + const glyph = + field.type === 'date' || field.type === 'datetime' ? ( + + ) : measure || ['integer', 'float', 'decimal'].includes(field.type) ? ( + '#' + ) : field.type === 'boolean' ? ( + 'T|F' + ) : field.type === 'json' || field.type === 'array' ? ( + '{ }' + ) : ( + 'Abc' + ) + return ( + + {glyph} + + ) +} + +function DataPaneSection({ + title, + fields, + measure, + emptyText, +}: { + title: string + fields: BIField[] + measure: boolean + emptyText: string +}): JSX.Element { + const { addFieldToShelf } = useActions(biEditorLogic) + + const addField = (field: BIField): void => { + const target = getBIDropTarget(field, 'rows') + addFieldToShelf(target.field, target.shelf) + } + + return ( +
+
{title}
+ {fields.length === 0 ? {emptyText} : null} + {fields.map((field) => ( + + ))} +
+ ) +} + +/** Lists the selected table's fields as dimensions and measures, ready to drag onto shelves. */ +export function BIDataPane(): JSX.Element { + const { + availableDataSources, + config, + databaseLoading, + dataPaneFields, + dataPaneFieldsLoading, + dataPaneSearch, + filteredDataPaneFields, + } = useValues(biEditorLogic) + const { setDataPaneSearch, setDataSource } = useActions(biEditorLogic) + const { setDatabaseTreeCollapsed } = useActions(editorSizingLogic) + const { locateTable } = useActions(queryDatabaseLogic) + + const hasFields = dataPaneFields.dimensions.length > 0 || dataPaneFields.measures.length > 0 + const hasMatches = filteredDataPaneFields.dimensions.length > 0 || filteredDataPaneFields.measures.length > 0 + + return ( +
+
+ Data + { + if (config.source) { + setDatabaseTreeCollapsed(false) + locateTable(config.source.table) + } + }} + > + Locate + +
+
+ ({ + value: getBIDataSourceKey(source), + label: source.table, + }))} + onSelect={(sourceKey) => { + const source = availableDataSources.find( + (candidate) => getBIDataSourceKey(candidate) === sourceKey + ) + if (source) { + setDataSource(source) + } + }} + icon={} + loading={databaseLoading} + disabledReason={ + !databaseLoading && availableDataSources.length === 0 + ? 'No tables available for this connection' + : undefined + } + placeholder="Select a table" + searchPlaceholder="Search tables" + searchInputDataAttr="bi-editor-data-source-search" + noResultsMessage="No matching tables" + size="small" + fullWidth + truncateText={{ maxWidthClass: 'max-w-full' }} + dropdownMaxContentWidth + data-attr="bi-editor-data-source" + /> + {config.source ? ( + + ) : null} +
+
+ {!config.source ? ( +

+ Select a table to list its fields. You can also drag columns from the database tree. +

+ ) : dataPaneFieldsLoading && !hasFields ? ( +
+ Loading fields +
+ ) : !hasFields ? ( +

+ No fields found. Drag columns from the database tree instead. +

+ ) : !hasMatches ? ( +

No matching fields

+ ) : ( + <> + + + + )} +
+
+ ) +} diff --git a/frontend/src/scenes/data-warehouse/editor/bi/components/BIExpressionPopover.tsx b/frontend/src/scenes/data-warehouse/editor/bi/components/BIExpressionPopover.tsx new file mode 100644 index 000000000000..f3d6f7fb0b1b --- /dev/null +++ b/frontend/src/scenes/data-warehouse/editor/bi/components/BIExpressionPopover.tsx @@ -0,0 +1,55 @@ +import { LemonDropdown } from '@posthog/lemon-ui' + +import { HogQLEditor } from 'lib/components/HogQLEditor/HogQLEditor' + +import { NodeKind } from '~/queries/schema/schema-general' +import { escapeDottedHogQLIdentifier } from '~/queries/utils' + +import { BIDataSource } from '../biEditorTypes' + +export function BIExpressionPopover({ + visible, + value, + source, + placeholder, + onChange, + onClose, + children, +}: { + visible: boolean + value: string + source: BIDataSource + placeholder?: string + onChange: (value: string) => void + onClose: () => void + children: React.ReactElement +}): JSX.Element { + return ( + !nextVisible && onClose()} + placement="bottom-start" + overlay={ +
+ { + onChange(nextValue) + onClose() + }} + /> +
+ } + > + {children} +
+ ) +} diff --git a/frontend/src/scenes/data-warehouse/editor/bi/components/BIFieldPill.tsx b/frontend/src/scenes/data-warehouse/editor/bi/components/BIFieldPill.tsx new file mode 100644 index 000000000000..7661821c17e4 --- /dev/null +++ b/frontend/src/scenes/data-warehouse/editor/bi/components/BIFieldPill.tsx @@ -0,0 +1,163 @@ +import { useActions, useValues } from 'kea' +import { useState } from 'react' + +import { IconArrowDown, IconArrowUp } from 'lib/lemon-ui/icons' +import { LemonMenu, LemonMenuItems } from 'lib/lemon-ui/LemonMenu' + +import { biEditorLogic } from '../biEditorLogic' +import { AGGREGATION_OPTIONS, DATE_BUCKET_OPTIONS, NUMERIC_AGGREGATIONS } from '../biEditorOptions' +import { + BIShelf, + getBIFieldPillLabel, + getBIValuePillLabel, + getBIValueSortKey, + isDateTimeBIField, + isNumericBIField, +} from '../biEditorTypes' +import { BIExpressionPopover } from './BIExpressionPopover' +import { BIPill } from './BIPill' + +type ExpressionTarget = 'field' | 'aggregation' + +/** A dimension on rows or columns, or a measure from the values shelf. */ +export function BIFieldPill({ + shelf, + index, +}: { + shelf: Exclude + index: number +}): JSX.Element | null { + const { config, activeExpressionEditorId } = useValues(biEditorLogic) + const { + addFieldToShelf, + moveFieldToShelf, + removeFieldFromShelf, + setActiveExpressionEditorId, + setFieldDateBucket, + setFieldExpression, + setSort, + setValueAggregation, + setValueCustomExpression, + } = useActions(biEditorLogic) + const [editing, setEditing] = useState(null) + + const value = shelf === 'values' ? config.values[index] : null + const field = value ? value.field : shelf === 'values' ? null : config[shelf][index] + if (!field) { + return null + } + + const isMeasure = shelf === 'values' + const autoOpen = activeExpressionEditorId === field.id + const expressionTarget: ExpressionTarget | null = editing ?? (autoOpen ? 'field' : null) + const sortKey = isMeasure ? getBIValueSortKey(config, index) : `${shelf}:${field.id}` + const otherDimensionShelf = shelf === 'rows' ? 'columns' : 'rows' + const label = value ? getBIValuePillLabel(value) : getBIFieldPillLabel(field) + const incomplete = value?.aggregation === 'custom' ? !value.customExpression?.trim() : !field.expression.trim() + + const items: LemonMenuItems = [ + isMeasure && value + ? { + title: 'Measure', + items: AGGREGATION_OPTIONS.map((option) => ({ + label: option.label, + active: value.aggregation === option.value, + disabledReason: + !isNumericBIField(field) && NUMERIC_AGGREGATIONS.includes(option.value) + ? 'This calculation needs a numeric field' + : undefined, + onClick: () => { + setValueAggregation(index, option.value) + if (option.value === 'custom' && !value.customExpression) { + setEditing('aggregation') + } + }, + })), + } + : null, + isDateTimeBIField(field) + ? { + title: 'Date', + items: DATE_BUCKET_OPTIONS.map((option) => ({ + label: option.label, + active: (field.dateBucket ?? null) === option.value, + onClick: () => setFieldDateBucket(shelf, index, option.value), + })), + } + : null, + { + items: [ + { label: 'Edit field expression', onClick: () => setEditing('field') }, + value?.aggregation === 'custom' + ? { label: 'Edit SQL aggregation', onClick: () => setEditing('aggregation') } + : null, + sortKey + ? { + label: 'Sort ascending', + icon: , + onClick: () => setSort({ key: sortKey, direction: 'asc' }), + } + : null, + sortKey + ? { + label: 'Sort descending', + icon: , + onClick: () => setSort({ key: sortKey, direction: 'desc' }), + } + : null, + ], + }, + { + items: [ + isMeasure + ? { label: 'Convert to dimension', onClick: () => moveFieldToShelf('values', index, 'rows') } + : { label: 'Convert to measure', onClick: () => moveFieldToShelf(shelf, index, 'values') }, + !isMeasure + ? { + label: `Move to ${otherDimensionShelf}`, + onClick: () => moveFieldToShelf(shelf, index, otherDimensionShelf), + } + : null, + { label: 'Add to filters', onClick: () => addFieldToShelf(field, 'filters') }, + ], + }, + { + items: [{ label: 'Remove', status: 'danger', onClick: () => removeFieldFromShelf(shelf, index) }], + }, + ] + + return ( + + expressionTarget === 'aggregation' + ? setValueCustomExpression(index, nextExpression) + : setFieldExpression(shelf, index, nextExpression) + } + onClose={() => { + setEditing(null) + if (autoOpen) { + setActiveExpressionEditorId(null) + } + }} + > + + + removeFieldFromShelf(shelf, index)} + aria-label={`${label} options`} + data-attr={`bi-editor-${shelf}-pill`} + /> + + + + ) +} diff --git a/frontend/src/scenes/data-warehouse/editor/bi/components/BIFilterEditor.tsx b/frontend/src/scenes/data-warehouse/editor/bi/components/BIFilterEditor.tsx new file mode 100644 index 000000000000..17dbefd1bbbd --- /dev/null +++ b/frontend/src/scenes/data-warehouse/editor/bi/components/BIFilterEditor.tsx @@ -0,0 +1,148 @@ +import { useActions, useValues } from 'kea' + +import { IconPencil } from '@posthog/icons' +import { LemonButton, LemonInput, LemonLabel, LemonSelect } from '@posthog/lemon-ui' + +import { HogQLDropdown } from 'lib/components/HogQLDropdown/HogQLDropdown' +import { dayjs } from 'lib/dayjs' +import { LemonCalendarSelectInput } from 'lib/lemon-ui/LemonCalendar/LemonCalendarSelect' + +import { biEditorLogic } from '../biEditorLogic' +import { DATE_BUCKET_OPTIONS, FILTER_OPERATOR_OPTIONS } from '../biEditorOptions' +import { isDateTimeBIField } from '../biEditorTypes' + +export function BIFilterEditor({ index, onDone }: { index: number; onDone: () => void }): JSX.Element | null { + const { config } = useValues(biEditorLogic) + const { + removeFieldFromShelf, + setFieldDateBucket, + setFieldExpression, + setFilterCustomExpression, + setFilterOperator, + setFilterValue, + } = useActions(biEditorLogic) + const filter = config.filters[index] + if (!filter) { + return null + } + + const { field } = filter + const needsValue = !['last_7_days', 'is_set', 'is_not_set', 'custom'].includes(filter.operator) + const includesTime = field.type === 'datetime' + const selectedDate = filter.value && dayjs(filter.value).isValid() ? dayjs(filter.value) : null + + return ( +
+
+ Field + setFieldExpression('filters', index, expression)} + tableName={field.source.table} + connectionId={field.source.connectionId} + size="small" + buttonIcon={} + buttonLabel={ + field.expression ? {field.expression} : 'Select field' + } + buttonAriaLabel={`Edit field expression for ${field.name || 'field'}`} + /> +
+ {isDateTimeBIField(field) ? ( +
+ Date part + setFieldDateBucket('filters', index, dateBucket)} + size="small" + data-attr="bi-editor-date-bucket" + /> +
+ ) : null} +
+ Condition + ({ + ...option, + disabledReason: + option.value === 'last_7_days' && !isDateTimeBIField(field) + ? 'Choose a date or date-time field' + : undefined, + }))} + onChange={(operator) => setFilterOperator(index, operator)} + size="small" + /> +
+ {filter.operator === 'custom' ? ( +
+ SQL condition + setFilterCustomExpression(index, customExpression)} + tableName={field.source.table} + connectionId={field.source.connectionId} + size="small" + buttonIcon={} + buttonLabel={ + filter.customExpression ? ( + {filter.customExpression} + ) : ( + 'Add SQL condition' + ) + } + buttonAriaLabel="Edit filter SQL condition" + /> +
+ ) : needsValue ? ( +
+ Value + {isDateTimeBIField(field) ? ( + + setFilterValue( + index, + date?.format(includesTime ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD') ?? '' + ) + } + granularity={includesTime ? 'minute' : 'day'} + format={includesTime ? 'MMM D, YYYY HH:mm' : 'MMM D, YYYY'} + use24HourFormat + clearable + placeholder={includesTime ? 'Select date and time' : 'Select date'} + buttonProps={{ size: 'small', 'aria-label': `${field.name} filter date` }} + /> + ) : ( + setFilterValue(index, value)} + onPressEnter={onDone} + placeholder="Value" + aria-label={`${field.name} filter value`} + size="small" + autoFocus + /> + )} +
+ ) : null} +
+ { + removeFieldFromShelf('filters', index) + onDone() + }} + > + Remove filter + + + Done + +
+
+ ) +} diff --git a/frontend/src/scenes/data-warehouse/editor/bi/components/BIFilterPill.tsx b/frontend/src/scenes/data-warehouse/editor/bi/components/BIFilterPill.tsx new file mode 100644 index 000000000000..f97bf7b96068 --- /dev/null +++ b/frontend/src/scenes/data-warehouse/editor/bi/components/BIFilterPill.tsx @@ -0,0 +1,64 @@ +import { useActions, useValues } from 'kea' +import { useState } from 'react' + +import { LemonDropdown } from '@posthog/lemon-ui' + +import { biEditorLogic } from '../biEditorLogic' +import { FILTER_OPERATOR_OPTIONS } from '../biEditorOptions' +import { BIFilter, getBIFieldPillLabel } from '../biEditorTypes' +import { BIFilterEditor } from './BIFilterEditor' +import { BIPill } from './BIPill' + +function filterSummary(filter: BIFilter): string | undefined { + const operatorLabel = FILTER_OPERATOR_OPTIONS.find((option) => option.value === filter.operator)?.label + if (filter.operator === 'custom') { + return filter.customExpression?.trim() || undefined + } + if (['last_7_days', 'is_set', 'is_not_set'].includes(filter.operator)) { + return operatorLabel?.toLowerCase() + } + return filter.value.trim() ? `${operatorLabel?.toLowerCase()} ${filter.value}` : undefined +} + +export function BIFilterPill({ index }: { index: number }): JSX.Element | null { + const { config, activeExpressionEditorId } = useValues(biEditorLogic) + const { removeFieldFromShelf, setActiveExpressionEditorId } = useActions(biEditorLogic) + const [open, setOpen] = useState(false) + const filter = config.filters[index] + if (!filter) { + return null + } + + const visible = open || activeExpressionEditorId === filter.field.id + const close = (): void => { + setOpen(false) + if (activeExpressionEditorId === filter.field.id) { + setActiveExpressionEditorId(null) + } + } + const label = getBIFieldPillLabel(filter.field) + const summary = filterSummary(filter) + + return ( + (nextVisible ? setOpen(true) : close())} + closeOnClickInside={false} + placement="right-start" + overlay={} + > + removeFieldFromShelf('filters', index)} + className="w-full justify-between" + aria-label={`${label} filter`} + data-attr="bi-editor-filters-pill" + /> + + ) +} diff --git a/frontend/src/scenes/data-warehouse/editor/bi/components/BIFiltersCard.tsx b/frontend/src/scenes/data-warehouse/editor/bi/components/BIFiltersCard.tsx new file mode 100644 index 000000000000..d86c6f6d6bc6 --- /dev/null +++ b/frontend/src/scenes/data-warehouse/editor/bi/components/BIFiltersCard.tsx @@ -0,0 +1,22 @@ +import { useValues } from 'kea' + +import { biEditorLogic } from '../biEditorLogic' +import { BIFilterPill } from './BIFilterPill' +import { BIShelfCard } from './BIShelfCard' +import { BIShelfDropTarget } from './BIShelfDropTarget' + +export function BIFiltersCard(): JSX.Element { + const { config } = useValues(biEditorLogic) + + return ( + + + {config.filters.length > 0 ? ( + config.filters.map((filter, index) => ) + ) : ( + Drop fields here to filter rows + )} + + + ) +} diff --git a/frontend/src/scenes/data-warehouse/editor/bi/components/BIMarksCard.tsx b/frontend/src/scenes/data-warehouse/editor/bi/components/BIMarksCard.tsx new file mode 100644 index 000000000000..dcd541a77caf --- /dev/null +++ b/frontend/src/scenes/data-warehouse/editor/bi/components/BIMarksCard.tsx @@ -0,0 +1,36 @@ +import { useActions, useValues } from 'kea' + +import { LemonSelect } from '@posthog/lemon-ui' + +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' + +import { biEditorLogic } from '../biEditorLogic' +import { getChartTypeOptions } from '../biEditorOptions' +import { BIShelfCard } from './BIShelfCard' + +export function BIMarksCard(): JSX.Element { + const { config } = useValues(biEditorLogic) + const { setChartType } = useActions(biEditorLogic) + const { featureFlags } = useValues(featureFlagLogic) + + return ( + + ({ + value: option.value, + label: option.label, + icon: option.icon, + }))} + onChange={setChartType} + size="small" + fullWidth + aria-label="Mark type" + data-attr="bi-editor-mark-type" + /> + + Measures on rows set the values. Change how a measure is calculated from its menu. + + + ) +} diff --git a/frontend/src/scenes/data-warehouse/editor/bi/components/BIPill.tsx b/frontend/src/scenes/data-warehouse/editor/bi/components/BIPill.tsx new file mode 100644 index 000000000000..9ce6318f4578 --- /dev/null +++ b/frontend/src/scenes/data-warehouse/editor/bi/components/BIPill.tsx @@ -0,0 +1,61 @@ +import { forwardRef } from 'react' + +import { IconChevronDown } from '@posthog/icons' + +import { cn } from 'lib/utils/css-classes' + +import { BIShelf, BI_SHELF_PILL_DRAG_MIME_TYPE, BIShelfPillDragData } from '../biEditorTypes' + +export type BIPillKind = 'dimension' | 'measure' | 'filter' + +export interface BIPillProps extends React.ButtonHTMLAttributes { + kind: BIPillKind + label: string + detail?: string + shelf: BIShelf + index: number + incomplete?: boolean + /** Called when the pill is dropped outside every shelf, which removes it. */ + onDropOutside: () => void +} + +/** A draggable field on a shelf. Blue for dimensions, green for measures, like desktop BI tools. */ +export const BIPill = forwardRef(function BIPill( + { kind, label, detail, shelf, index, incomplete, onDropOutside, className, ...buttonProps }, + ref +) { + return ( + + ) +}) diff --git a/frontend/src/scenes/data-warehouse/editor/bi/components/BIShelfCard.tsx b/frontend/src/scenes/data-warehouse/editor/bi/components/BIShelfCard.tsx new file mode 100644 index 000000000000..afa5cc5b5796 --- /dev/null +++ b/frontend/src/scenes/data-warehouse/editor/bi/components/BIShelfCard.tsx @@ -0,0 +1,11 @@ +import type { ReactNode } from 'react' + +/** A titled card in the column next to the data pane, like the Filters and Marks cards. */ +export function BIShelfCard({ title, children }: { title: string; children: ReactNode }): JSX.Element { + return ( +
+

{title}

+ {children} +
+ ) +} diff --git a/frontend/src/scenes/data-warehouse/editor/bi/components/BIShelfDropTarget.tsx b/frontend/src/scenes/data-warehouse/editor/bi/components/BIShelfDropTarget.tsx new file mode 100644 index 000000000000..9484b9d3c1d5 --- /dev/null +++ b/frontend/src/scenes/data-warehouse/editor/bi/components/BIShelfDropTarget.tsx @@ -0,0 +1,86 @@ +import { useActions, useValues } from 'kea' +import type { DragEvent, ReactNode } from 'react' + +import { cn } from 'lib/utils/css-classes' + +import { biEditorLogic } from '../biEditorLogic' +import { + BIShelf, + BI_FIELD_DRAG_MIME_TYPE, + BI_SHELF_PILL_DRAG_MIME_TYPE, + getBIDropTarget, + parseBIField, + parseBIShelfPillDragData, +} from '../biEditorTypes' + +function carriesBIDrag(event: DragEvent): 'field' | 'pill' | null { + const types = Array.from(event.dataTransfer.types) + return types.includes(BI_SHELF_PILL_DRAG_MIME_TYPE) + ? 'pill' + : types.includes(BI_FIELD_DRAG_MIME_TYPE) + ? 'field' + : null +} + +/** Accepts fields from the data pane or the database tree, and pills moved from other shelves. */ +export function BIShelfDropTarget({ + shelf, + className, + children, +}: { + shelf: BIShelf + className?: string + children: ReactNode +}): JSX.Element { + const { activeDropShelf } = useValues(biEditorLogic) + const { addFieldToShelf, clearActiveDropShelf, moveFieldToShelf, setActiveDropShelf } = useActions(biEditorLogic) + + return ( +
{ + if (carriesBIDrag(event)) { + setActiveDropShelf(shelf) + } + }} + onDragOver={(event) => { + const dragKind = carriesBIDrag(event) + if (dragKind) { + event.preventDefault() + event.dataTransfer.dropEffect = dragKind === 'pill' ? 'move' : 'copy' + } + }} + onDragLeave={(event) => { + const nextTarget = event.relatedTarget + if (!nextTarget || !event.currentTarget.contains(nextTarget as Node)) { + clearActiveDropShelf(shelf) + } + }} + onDrop={(event) => { + event.preventDefault() + clearActiveDropShelf(shelf) + const pill = parseBIShelfPillDragData(event.dataTransfer.getData(BI_SHELF_PILL_DRAG_MIME_TYPE)) + if (pill) { + // Measures sit on the rows strip, so dropping one on rows or columns keeps it a measure + const keepsMeasure = pill.shelf === 'values' && (shelf === 'rows' || shelf === 'columns') + if (!keepsMeasure) { + moveFieldToShelf(pill.shelf, pill.index, shelf) + } + return + } + const field = parseBIField(event.dataTransfer.getData(BI_FIELD_DRAG_MIME_TYPE)) + if (field) { + const target = getBIDropTarget(field, shelf) + addFieldToShelf(target.field, target.shelf) + } + }} + > + {children} +
+ ) +} diff --git a/frontend/src/scenes/data-warehouse/editor/bi/components/BIShelfStrip.tsx b/frontend/src/scenes/data-warehouse/editor/bi/components/BIShelfStrip.tsx new file mode 100644 index 000000000000..ac49cae7f114 --- /dev/null +++ b/frontend/src/scenes/data-warehouse/editor/bi/components/BIShelfStrip.tsx @@ -0,0 +1,50 @@ +import { useActions, useValues } from 'kea' +import type { ReactNode } from 'react' + +import { IconPlus } from '@posthog/icons' +import { LemonButton } from '@posthog/lemon-ui' + +import { biEditorLogic } from '../biEditorLogic' +import { BIShelf } from '../biEditorTypes' +import { BIShelfDropTarget } from './BIShelfDropTarget' + +/** A horizontal Rows or Columns shelf above the view. */ +export function BIShelfStrip({ + shelf, + title, + icon, + emptyText, + children, +}: { + shelf: Extract + title: string + icon: ReactNode + emptyText: string + children: ReactNode[] +}): JSX.Element { + const { config } = useValues(biEditorLogic) + const { addBlankFieldToShelf } = useActions(biEditorLogic) + + return ( +
+
+ {icon} + {title} +
+ + {children.length > 0 ? children : {emptyText}} + } + size="xsmall" + type="tertiary" + className="ml-auto" + tooltip="Add a calculated field" + aria-label={`Add a calculated field to ${title.toLowerCase()}`} + disabledReason={!config.source ? 'Select a data source first' : undefined} + onClick={() => addBlankFieldToShelf(shelf)} + data-attr={`bi-editor-${shelf}-add-field`} + /> + +
+ ) +} diff --git a/frontend/src/scenes/data-warehouse/editor/bi/components/BIShowMe.tsx b/frontend/src/scenes/data-warehouse/editor/bi/components/BIShowMe.tsx new file mode 100644 index 000000000000..9ac061eddc73 --- /dev/null +++ b/frontend/src/scenes/data-warehouse/editor/bi/components/BIShowMe.tsx @@ -0,0 +1,78 @@ +import { useActions, useValues } from 'kea' +import { useState } from 'react' + +import { IconX } from '@posthog/icons' +import { LemonButton } from '@posthog/lemon-ui' + +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' +import { cn } from 'lib/utils/css-classes' + +import { ChartDisplayType } from '~/types' + +import { biEditorLogic } from '../biEditorLogic' +import { getChartTypeOptions } from '../biEditorOptions' + +/** Chart picker that highlights the chart types that suit the fields on the shelves. */ +export function BIShowMe({ docked }: { docked: boolean }): JSX.Element { + const { chartFits, config } = useValues(biEditorLogic) + const { setChartType, setShowMeOpen } = useActions(biEditorLogic) + const { featureFlags } = useValues(featureFlagLogic) + const [hoveredChartType, setHoveredChartType] = useState(null) + + const options = getChartTypeOptions(featureFlags) + const describedOption = + options.find((option) => option.value === hoveredChartType) ?? + options.find((option) => option.value === config.chartType) + const describedFit = describedOption ? chartFits[describedOption.value] : undefined + + return ( +
+
+ Show me + {docked ? ( + } + size="xsmall" + type="tertiary" + tooltip="Hide chart picker" + aria-label="Hide chart picker" + onClick={() => setShowMeOpen(false)} + /> + ) : null} +
+
+ {options.map((option) => { + const fits = chartFits[option.value]?.fits ?? true + const selected = config.chartType === option.value + return ( + setChartType(option.value)} + onMouseEnter={() => setHoveredChartType(option.value)} + onMouseLeave={() => setHoveredChartType(null)} + data-attr={`bi-editor-chart-type-${option.value}`} + /> + ) + })} +
+ {describedOption && describedFit ? ( +
+
{describedOption.label}
+
+ {describedOption.value === ChartDisplayType.Auto + ? `${describedFit.requirement}.` + : `${describedFit.fits ? 'Uses' : 'Needs'} ${describedFit.requirement}.`} +
+
+ ) : null} +
+ ) +} diff --git a/frontend/src/scenes/data-warehouse/editor/bi/components/BIToolbar.tsx b/frontend/src/scenes/data-warehouse/editor/bi/components/BIToolbar.tsx new file mode 100644 index 000000000000..6512f585cac7 --- /dev/null +++ b/frontend/src/scenes/data-warehouse/editor/bi/components/BIToolbar.tsx @@ -0,0 +1,139 @@ +import { useActions, useValues } from 'kea' + +import { IconSort } from '@posthog/icons' +import { LemonButton, LemonDivider, LemonDropdown, LemonSelect, LemonSwitch } from '@posthog/lemon-ui' + +import { IconArrowDown, IconArrowUp, IconSwapHoriz } from 'lib/lemon-ui/icons' + +import { biEditorLogic } from '../biEditorLogic' +import { LIMIT_OPTIONS } from '../biEditorOptions' +import { BISortDirection } from '../biEditorTypes' +import { BIShowMe } from './BIShowMe' + +export function BIToolbar(): JSX.Element { + const { autoUpdate, config, showMeOpen, sortOptions } = useValues(biEditorLogic) + const { resetConfig, setAutoUpdate, setLimit, setShowMeOpen, setSort, swapRowsAndColumns } = + useActions(biEditorLogic) + + // Quick sort follows the chosen sort field, or the first measure like desktop BI tools do + const quickSortKey = config.sort?.key ?? sortOptions.find((option) => option.key.startsWith('values:'))?.key ?? null + const quickSort = (direction: BISortDirection): void => { + if (quickSortKey) { + setSort({ key: quickSortKey, direction }) + } + } + const noSortReason = quickSortKey ? undefined : 'Add a field to rows or columns first' + + return ( +
+ } + size="small" + type="tertiary" + tooltip="Swap rows and columns" + aria-label="Swap rows and columns" + disabledReason={ + config.rows.length === 0 && config.columns.length === 0 ? 'No fields to swap' : undefined + } + onClick={swapRowsAndColumns} + data-attr="bi-editor-swap" + /> + } + size="small" + type="tertiary" + active={config.sort?.direction === 'asc'} + tooltip="Sort ascending" + aria-label="Sort ascending" + disabledReason={noSortReason} + onClick={() => quickSort('asc')} + data-attr="bi-editor-sort-ascending" + /> + } + size="small" + type="tertiary" + active={config.sort?.direction === 'desc'} + tooltip="Sort descending" + aria-label="Sort descending" + disabledReason={noSortReason} + onClick={() => quickSort('desc')} + data-attr="bi-editor-sort-descending" + /> + ({ value: option.key, label: option.label })), + ]} + onChange={(key) => setSort(key === null ? null : { key, direction: config.sort?.direction ?? 'desc' })} + renderButtonContent={(option) => `Sort: ${option?.label ?? 'Auto'}`} + icon={} + aria-label="Sort results by" + size="small" + type="tertiary" + dropdownMatchSelectWidth={false} + disabledReason={sortOptions.length === 0 ? 'Add a field to rows or columns first' : undefined} + data-attr="bi-editor-sort" + /> + `Limit: ${option?.label ?? config.limit}`} + aria-label="Query row limit" + size="small" + type="tertiary" + dropdownMatchSelectWidth={false} + data-attr="bi-editor-query-limit" + /> + + + Clear sheet + +
+ + {/* Narrow sheets have no room to dock the chart picker, so it opens as a dropdown */} + } placement="bottom-end"> + + Show me + + + {!showMeOpen ? ( + setShowMeOpen(true)} + data-attr="bi-editor-show-me" + > + Show me + + ) : null} +
+
+ ) +} diff --git a/frontend/src/scenes/data-warehouse/editor/editorSizingLogic.tsx b/frontend/src/scenes/data-warehouse/editor/editorSizingLogic.tsx index 2f645ee9d9d2..87fc3b2ab660 100644 --- a/frontend/src/scenes/data-warehouse/editor/editorSizingLogic.tsx +++ b/frontend/src/scenes/data-warehouse/editor/editorSizingLogic.tsx @@ -23,8 +23,9 @@ const MINIMUM_NAVIGATOR_WIDTH = 100 const NAVIGATOR_DEFAULT_WIDTH = 350 const MINIMUM_QUERY_PANE_HEIGHT = 100 const DEFAULT_QUERY_PANE_HEIGHT = 300 -const MINIMUM_BI_EDITOR_HEIGHT = 180 -const DEFAULT_BI_EDITOR_HEIGHT = 324 +const MINIMUM_BI_SIDE_PANE_WIDTH = 200 +const DEFAULT_BI_SIDE_PANE_WIDTH = 240 +const MAXIMUM_BI_SIDE_PANE_WIDTH = 480 const MINIMUM_SIDEBAR_WIDTH = 150 export const SIDEBAR_DEFAULT_WIDTH = 300 const MAXIMUM_SIDEBAR_WIDTH = 550 @@ -42,7 +43,7 @@ export interface editorSizingLogicValues { queryPaneDesiredSize: number | null // resizerLogic sidebarDesiredSize: number | null // resizerLogic sourceNavigatorDesiredSize: number | null // resizerLogic - biEditorHeight: number + biSidePaneWidth: number biEditorResizerProps: ResizerLogicProps databaseTreeResizerProps: ResizerLogicProps databaseTreeWidth: number @@ -88,7 +89,7 @@ export interface editorSizingLogicMeta { key: string __keaTypeGenInternalSelectorTypes: { editorSceneRef: (editorSceneRef: any) => any - biEditorHeight: (biEditorDesiredSize: number | null) => number + biSidePaneWidth: (biEditorDesiredSize: number | null) => number biEditorResizerProps: (biEditorResizerProps: ResizerLogicProps) => ResizerLogicProps sourceNavigatorWidth: (sourceNavigatorDesiredSize: number | null) => number queryPaneHeight: ( @@ -182,9 +183,13 @@ export const editorSizingLogic = kea([ })), selectors({ editorSceneRef: [(p) => [p.editorSceneRef], (editorSceneRef) => editorSceneRef], - biEditorHeight: [ + biSidePaneWidth: [ (s) => [s.biEditorDesiredSize], - (desiredSize: number | null) => Math.max(desiredSize || DEFAULT_BI_EDITOR_HEIGHT, MINIMUM_BI_EDITOR_HEIGHT), + (desiredSize: number | null) => + Math.min( + Math.max(desiredSize || DEFAULT_BI_SIDE_PANE_WIDTH, MINIMUM_BI_SIDE_PANE_WIDTH), + MAXIMUM_BI_SIDE_PANE_WIDTH + ), ], biEditorResizerProps: [ (_, p) => [p.biEditorResizerProps], From bcc75f4c76e242693b32990fd4d3f6a3373e33ef Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Wed, 30 Sep 2026 01:58:25 +0200 Subject: [PATCH 02/48] chore(sql-editor): sync generated kea types for bi editor Co-Authored-By: Claude Opus 5.5 --- frontend/src/scenes/data-warehouse/editor/bi/biEditorLogic.ts | 4 ++-- .../src/scenes/data-warehouse/editor/editorSizingLogic.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/scenes/data-warehouse/editor/bi/biEditorLogic.ts b/frontend/src/scenes/data-warehouse/editor/bi/biEditorLogic.ts index 822c8c4c685f..49fe07286fdb 100644 --- a/frontend/src/scenes/data-warehouse/editor/bi/biEditorLogic.ts +++ b/frontend/src/scenes/data-warehouse/editor/bi/biEditorLogic.ts @@ -399,12 +399,12 @@ export interface biEditorLogicMeta { posthogTables: DatabaseSchemaTable[], databaseConnectionId: string | null ) => BIDataSource[] + generatedQuery: (config: BIConfig) => BIQueryBuildResult | null + sortOptions: (config: BIConfig) => BISortOption[] chartFits: (config: BIConfig) => Partial> dataPaneFields: (config: BIConfig, allTables: DatabaseSchemaTable[]) => BIDataPaneFields dataPaneFieldsLoading: (config: BIConfig, tableFieldsStatus: TableFieldsStatus) => boolean filteredDataPaneFields: (dataPaneFields: BIDataPaneFields, dataPaneSearch: string) => BIDataPaneFields - generatedQuery: (config: BIConfig) => BIQueryBuildResult | null - sortOptions: (config: BIConfig) => BISortOption[] } } diff --git a/frontend/src/scenes/data-warehouse/editor/editorSizingLogic.tsx b/frontend/src/scenes/data-warehouse/editor/editorSizingLogic.tsx index 87fc3b2ab660..6211dc7c8dbf 100644 --- a/frontend/src/scenes/data-warehouse/editor/editorSizingLogic.tsx +++ b/frontend/src/scenes/data-warehouse/editor/editorSizingLogic.tsx @@ -43,8 +43,8 @@ export interface editorSizingLogicValues { queryPaneDesiredSize: number | null // resizerLogic sidebarDesiredSize: number | null // resizerLogic sourceNavigatorDesiredSize: number | null // resizerLogic - biSidePaneWidth: number biEditorResizerProps: ResizerLogicProps + biSidePaneWidth: number databaseTreeResizerProps: ResizerLogicProps databaseTreeWidth: number databaseTreeWillCollapse: boolean From 0354e4420069736d703995e777cbf658e269c9b2 Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Wed, 30 Sep 2026 09:19:36 +0200 Subject: [PATCH 03/48] fix(sql-editor): scope bi editors to one shelf and measure the data pane Co-Authored-By: Claude Opus 5.5 --- .../src/scenes/data-warehouse/editor/bi/BIEditor.tsx | 4 ++-- .../scenes/data-warehouse/editor/bi/biEditorLogic.ts | 5 +++-- .../scenes/data-warehouse/editor/bi/biEditorTypes.ts | 5 +++++ .../editor/bi/components/BIFieldPill.tsx | 3 ++- .../editor/bi/components/BIFilterPill.tsx | 7 ++++--- .../data-warehouse/editor/sqlEditorLogic.test.ts | 11 +++++++++-- 6 files changed, 25 insertions(+), 10 deletions(-) diff --git a/frontend/src/scenes/data-warehouse/editor/bi/BIEditor.tsx b/frontend/src/scenes/data-warehouse/editor/bi/BIEditor.tsx index 712faeb1e961..3c7a15c063c9 100644 --- a/frontend/src/scenes/data-warehouse/editor/bi/BIEditor.tsx +++ b/frontend/src/scenes/data-warehouse/editor/bi/BIEditor.tsx @@ -28,12 +28,12 @@ export function BIEditor({ tabId, children }: { tabId: string; children: ReactNo
-
+ {/* The resizer measures the data pane alone, because the cards column has a fixed width */} +
diff --git a/frontend/src/scenes/data-warehouse/editor/bi/biEditorLogic.ts b/frontend/src/scenes/data-warehouse/editor/bi/biEditorLogic.ts index 49fe07286fdb..c98c586b9bdc 100644 --- a/frontend/src/scenes/data-warehouse/editor/bi/biEditorLogic.ts +++ b/frontend/src/scenes/data-warehouse/editor/bi/biEditorLogic.ts @@ -32,6 +32,7 @@ import { defaultAggregationForField, getBIChartFit, getBIDataPaneFields, + getBIShelfEditorKey, getBISortOptions, isBIFieldCompatible, normalizeBIConfig, @@ -498,10 +499,10 @@ export const biEditorLogic = kea([ activeExpressionEditorId: [ null as string | null, { - addBlankFieldToShelf: (_, { fieldId }) => fieldId, + addBlankFieldToShelf: (_, { shelf, fieldId }) => getBIShelfEditorKey(shelf, fieldId), // Opens the filter editor as soon as a field lands on the filters shelf addFieldToShelf: (activeExpressionEditorId, { field, shelf }) => - shelf === 'filters' ? field.id : activeExpressionEditorId, + shelf === 'filters' ? getBIShelfEditorKey('filters', field.id) : activeExpressionEditorId, setActiveExpressionEditorId: (_, { fieldId }) => fieldId, resetConfig: () => null, restoreState: () => null, diff --git a/frontend/src/scenes/data-warehouse/editor/bi/biEditorTypes.ts b/frontend/src/scenes/data-warehouse/editor/bi/biEditorTypes.ts index f89e6dfee2cf..d15bd6a136a2 100644 --- a/frontend/src/scenes/data-warehouse/editor/bi/biEditorTypes.ts +++ b/frontend/src/scenes/data-warehouse/editor/bi/biEditorTypes.ts @@ -55,6 +55,11 @@ export function getBIDataSourceKey(source: BIDataSource): string { return JSON.stringify([source.connectionId ?? null, source.table]) } +/** Keys an open editor by shelf too, because the same field can sit on several shelves at once. */ +export function getBIShelfEditorKey(shelf: BIShelf, fieldId: string): string { + return `${shelf}:${fieldId}` +} + export function getBIFieldId(source: BIDataSource, expression: string): string { return JSON.stringify([source.connectionId ?? null, source.table, expression]) } diff --git a/frontend/src/scenes/data-warehouse/editor/bi/components/BIFieldPill.tsx b/frontend/src/scenes/data-warehouse/editor/bi/components/BIFieldPill.tsx index 7661821c17e4..a8b85cb477fe 100644 --- a/frontend/src/scenes/data-warehouse/editor/bi/components/BIFieldPill.tsx +++ b/frontend/src/scenes/data-warehouse/editor/bi/components/BIFieldPill.tsx @@ -9,6 +9,7 @@ import { AGGREGATION_OPTIONS, DATE_BUCKET_OPTIONS, NUMERIC_AGGREGATIONS } from ' import { BIShelf, getBIFieldPillLabel, + getBIShelfEditorKey, getBIValuePillLabel, getBIValueSortKey, isDateTimeBIField, @@ -48,7 +49,7 @@ export function BIFieldPill({ } const isMeasure = shelf === 'values' - const autoOpen = activeExpressionEditorId === field.id + const autoOpen = activeExpressionEditorId === getBIShelfEditorKey(shelf, field.id) const expressionTarget: ExpressionTarget | null = editing ?? (autoOpen ? 'field' : null) const sortKey = isMeasure ? getBIValueSortKey(config, index) : `${shelf}:${field.id}` const otherDimensionShelf = shelf === 'rows' ? 'columns' : 'rows' diff --git a/frontend/src/scenes/data-warehouse/editor/bi/components/BIFilterPill.tsx b/frontend/src/scenes/data-warehouse/editor/bi/components/BIFilterPill.tsx index f97bf7b96068..6c15b5843a7b 100644 --- a/frontend/src/scenes/data-warehouse/editor/bi/components/BIFilterPill.tsx +++ b/frontend/src/scenes/data-warehouse/editor/bi/components/BIFilterPill.tsx @@ -5,7 +5,7 @@ import { LemonDropdown } from '@posthog/lemon-ui' import { biEditorLogic } from '../biEditorLogic' import { FILTER_OPERATOR_OPTIONS } from '../biEditorOptions' -import { BIFilter, getBIFieldPillLabel } from '../biEditorTypes' +import { BIFilter, getBIFieldPillLabel, getBIShelfEditorKey } from '../biEditorTypes' import { BIFilterEditor } from './BIFilterEditor' import { BIPill } from './BIPill' @@ -29,10 +29,11 @@ export function BIFilterPill({ index }: { index: number }): JSX.Element | null { return null } - const visible = open || activeExpressionEditorId === filter.field.id + const editorKey = getBIShelfEditorKey('filters', filter.field.id) + const visible = open || activeExpressionEditorId === editorKey const close = (): void => { setOpen(false) - if (activeExpressionEditorId === filter.field.id) { + if (activeExpressionEditorId === editorKey) { setActiveExpressionEditorId(null) } } diff --git a/frontend/src/scenes/data-warehouse/editor/sqlEditorLogic.test.ts b/frontend/src/scenes/data-warehouse/editor/sqlEditorLogic.test.ts index 6a731247650a..c68f8e3e8ccb 100644 --- a/frontend/src/scenes/data-warehouse/editor/sqlEditorLogic.test.ts +++ b/frontend/src/scenes/data-warehouse/editor/sqlEditorLogic.test.ts @@ -33,7 +33,7 @@ import { metricsLogic } from 'products/data_catalog/frontend/metricsLogic' import { BI_EDITOR_EVENTS } from './bi/biEditorAnalytics' import { biEditorLogic } from './bi/biEditorLogic' -import { BIConfig, BIEditorView, BIField } from './bi/biEditorTypes' +import { BIConfig, BIEditorView, BIField, getBIShelfEditorKey } from './bi/biEditorTypes' import { buildSqlNotebook, editorSceneLogic } from './editorSceneLogic' import { OutputTab } from './outputPaneLogic' import { SELECTION_NOT_A_QUERY } from './saveCandidateProblems' @@ -2325,12 +2325,19 @@ describe('sqlEditorLogic', () => { source: { table: 'persons' }, }), ]) - expect(biLogic.values.activeExpressionEditorId).toEqual(biLogic.values.config.rows[0].id) + const blankField = biLogic.values.config.rows[0] + expect(biLogic.values.activeExpressionEditorId).toEqual(getBIShelfEditorKey('rows', blankField.id)) expect(logic.values.queryInput).toEqual( ['SELECT', ' count(*) AS count', 'FROM persons', 'LIMIT 1000'].join('\n') ) expect(router.values.hashParams.bi).toEqual(biLogic.values.config) + await expectLogic(biLogic, () => + biLogic.actions.addFieldToShelf(blankField, 'filters') + ).toFinishAllListeners() + + expect(biLogic.values.activeExpressionEditorId).toEqual(getBIShelfEditorKey('filters', blankField.id)) + biLogic.unmount() }) From 7fc468fb47ee119e84a611d0f71b5c2d0bc7d59c Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Wed, 30 Sep 2026 09:31:57 +0200 Subject: [PATCH 04/48] chore(sql-editor): add a bi mode worksheet story Co-Authored-By: Claude Opus 5.5 --- .../editor/SQLEditorScene.stories.tsx | 79 ++++++++++++++++++- 1 file changed, 78 insertions(+), 1 deletion(-) diff --git a/frontend/src/scenes/data-warehouse/editor/SQLEditorScene.stories.tsx b/frontend/src/scenes/data-warehouse/editor/SQLEditorScene.stories.tsx index c73d115721de..13219ade1939 100644 --- a/frontend/src/scenes/data-warehouse/editor/SQLEditorScene.stories.tsx +++ b/frontend/src/scenes/data-warehouse/editor/SQLEditorScene.stories.tsx @@ -2,13 +2,15 @@ import { Decorator, Meta, StoryObj } from '@storybook/react' import { BindLogic } from 'kea' import { useEffect, useRef } from 'react' +import { FEATURE_FLAGS } from 'lib/constants' import { App } from 'scenes/App' import { urls } from 'scenes/urls' import { mswDecorator } from '~/mocks/browser' import type { DataWarehouseSavedQuery } from '~/types' -import { AccessControlLevel, AccessControlResourceType } from '~/types' +import { AccessControlLevel, AccessControlResourceType, ChartDisplayType } from '~/types' +import { BIConfig, BIField, buildBIQuery } from './bi/biEditorTypes' import { QueryInfo } from './output-pane-tabs/QueryInfo' import { sqlEditorLogic } from './sqlEditorLogic' @@ -102,6 +104,40 @@ const MANAGED_WAREHOUSE_CONNECTIONS = [ }, ] +// A worksheet restored from the URL, so the snapshot shows every shelf holding a pill +const BI_EVENTS_SOURCE = { table: 'events' } +const biEventsField = (name: string, type: BIField['type']): BIField => ({ + id: JSON.stringify([null, 'events', name]), + name, + expression: name, + type, + source: BI_EVENTS_SOURCE, +}) +const BI_WORKSHEET_CONFIG: BIConfig = { + source: BI_EVENTS_SOURCE, + chartType: ChartDisplayType.ActionsLineGraph, + rows: [{ ...biEventsField('timestamp', 'datetime'), dateBucket: 'day' }], + columns: [biEventsField('event', 'string')], + values: [{ field: biEventsField('revenue', 'float'), aggregation: 'sum' }], + filters: [{ field: biEventsField('event', 'string'), operator: 'equals', value: 'purchase' }], + limit: 1000, + sort: null, +} +const BI_EVENTS_FIELDS = Object.fromEntries( + ( + [ + ['event', 'string'], + ['distinct_id', 'string'], + ['timestamp', 'datetime'], + ['$is_bot', 'boolean'], + ['properties', 'json'], + ['user_id', 'integer'], + ['revenue', 'float'], + ['duration_ms', 'integer'], + ] as const + ).map(([name, type]) => [name, { name, hogql_value: name, type, schema_valid: true }]) +) + const meta: Meta = { component: App, title: 'Scenes-App/Data Warehouse/SQL Editor', @@ -225,6 +261,47 @@ export const MaterializationSettings: StoryObj = { ], } +export const BIModeWorksheet: Story = { + parameters: { + featureFlags: [FEATURE_FLAGS.SQL_EDITOR_BI_MODE], + // The editor restores BI state only alongside the query it generated + pageUrl: `${urls.sqlEditor()}#${new URLSearchParams({ + q: buildBIQuery(BI_WORKSHEET_CONFIG)?.query ?? '', + mode: 'bi', + bi: JSON.stringify(BI_WORKSHEET_CONFIG), + })}`, + testOptions: { + waitForSelector: '[data-attr="bi-editor-data-pane-measure"]', + viewport: { width: 1600, height: 900 }, + }, + msw: { + mocks: { + post: { + '/api/environments/:team_id/query/:kind': async ({ request }: { request: Request }) => { + const body = (await request.json()) as Record + if (body?.query?.kind === 'DatabaseSchemaQuery') { + return [ + 200, + { + tables: { + events: { + id: 'events', + name: 'events', + type: 'posthog', + fields: BI_EVENTS_FIELDS, + }, + }, + }, + ] + } + return [200, { errors: [], warnings: [], notices: [], isValid: true }] + }, + }, + }, + }, + }, +} + export const LazySchema: Story = { parameters: { pageUrl: urls.sqlEditor({ query: 'SELECT * FROM events LIMIT 100' }), From 3bd67f2ab344af9827ce9779fb66a3040936fd7d Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Wed, 30 Sep 2026 10:48:32 +0200 Subject: [PATCH 05/48] fix(sql-editor): keep the chosen bi table selectable and fix the story Co-Authored-By: Claude Opus 5.5 --- .../editor/SQLEditorScene.stories.tsx | 26 ++++++------------- .../data-warehouse/editor/bi/biEditorLogic.ts | 16 ++++++++++++ .../editor/bi/components/BIDataPane.tsx | 5 ++-- .../editor/sqlEditorLogic.test.ts | 4 +++ 4 files changed, 31 insertions(+), 20 deletions(-) diff --git a/frontend/src/scenes/data-warehouse/editor/SQLEditorScene.stories.tsx b/frontend/src/scenes/data-warehouse/editor/SQLEditorScene.stories.tsx index 13219ade1939..091dc499ec8e 100644 --- a/frontend/src/scenes/data-warehouse/editor/SQLEditorScene.stories.tsx +++ b/frontend/src/scenes/data-warehouse/editor/SQLEditorScene.stories.tsx @@ -276,25 +276,15 @@ export const BIModeWorksheet: Story = { }, msw: { mocks: { + get: { + '/api/projects/:team_id/warehouse_expressions/': { results: [] }, + }, post: { - '/api/environments/:team_id/query/:kind': async ({ request }: { request: Request }) => { - const body = (await request.json()) as Record - if (body?.query?.kind === 'DatabaseSchemaQuery') { - return [ - 200, - { - tables: { - events: { - id: 'events', - name: 'events', - type: 'posthog', - fields: BI_EVENTS_FIELDS, - }, - }, - }, - ] - } - return [200, { errors: [], warnings: [], notices: [], isValid: true }] + // The specific path wins over the catch-all query mock on the meta + '/api/environments/:team_id/query/DatabaseSchemaQuery/': { + tables: { + events: { id: 'events', name: 'events', type: 'posthog', fields: BI_EVENTS_FIELDS }, + }, }, }, }, diff --git a/frontend/src/scenes/data-warehouse/editor/bi/biEditorLogic.ts b/frontend/src/scenes/data-warehouse/editor/bi/biEditorLogic.ts index c98c586b9bdc..e3b584fb169d 100644 --- a/frontend/src/scenes/data-warehouse/editor/bi/biEditorLogic.ts +++ b/frontend/src/scenes/data-warehouse/editor/bi/biEditorLogic.ts @@ -32,6 +32,7 @@ import { defaultAggregationForField, getBIChartFit, getBIDataPaneFields, + getBIDataSourceKey, getBIShelfEditorKey, getBISortOptions, isBIFieldCompatible, @@ -236,6 +237,7 @@ export interface biEditorLogicValues { editorView: BIEditorView filteredDataPaneFields: BIDataPaneFields generatedQuery: BIQueryBuildResult | null + selectableDataSources: BIDataSource[] showMeOpen: boolean sortOptions: BISortOption[] } @@ -400,6 +402,7 @@ export interface biEditorLogicMeta { posthogTables: DatabaseSchemaTable[], databaseConnectionId: string | null ) => BIDataSource[] + selectableDataSources: (availableDataSources: BIDataSource[], config: BIConfig) => BIDataSource[] generatedQuery: (config: BIConfig) => BIQueryBuildResult | null sortOptions: (config: BIConfig) => BISortOption[] chartFits: (config: BIConfig) => Partial> @@ -594,6 +597,19 @@ export const biEditorLogic = kea([ .sort((first, second) => first.table.localeCompare(second.table)) }, ], + selectableDataSources: [ + (selectors) => [selectors.availableDataSources, selectors.config], + (availableDataSources: BIDataSource[], config: BIConfig): BIDataSource[] => { + const source = config.source + // Keeps the chosen table selectable while tables load, or after it leaves the list + return !source || + availableDataSources.some( + (candidate) => getBIDataSourceKey(candidate) === getBIDataSourceKey(source) + ) + ? availableDataSources + : [source, ...availableDataSources] + }, + ], generatedQuery: [ (selectors) => [selectors.config], (config: BIConfig): BIQueryBuildResult | null => buildBIQuery(config), diff --git a/frontend/src/scenes/data-warehouse/editor/bi/components/BIDataPane.tsx b/frontend/src/scenes/data-warehouse/editor/bi/components/BIDataPane.tsx index 0aa0a3a32a78..0b6556c822e8 100644 --- a/frontend/src/scenes/data-warehouse/editor/bi/components/BIDataPane.tsx +++ b/frontend/src/scenes/data-warehouse/editor/bi/components/BIDataPane.tsx @@ -103,6 +103,7 @@ export function BIDataPane(): JSX.Element { dataPaneFieldsLoading, dataPaneSearch, filteredDataPaneFields, + selectableDataSources, } = useValues(biEditorLogic) const { setDataPaneSearch, setDataSource } = useActions(biEditorLogic) const { setDatabaseTreeCollapsed } = useActions(editorSizingLogic) @@ -133,12 +134,12 @@ export function BIDataPane(): JSX.Element {
({ + options={selectableDataSources.map((source) => ({ value: getBIDataSourceKey(source), label: source.table, }))} onSelect={(sourceKey) => { - const source = availableDataSources.find( + const source = selectableDataSources.find( (candidate) => getBIDataSourceKey(candidate) === sourceKey ) if (source) { diff --git a/frontend/src/scenes/data-warehouse/editor/sqlEditorLogic.test.ts b/frontend/src/scenes/data-warehouse/editor/sqlEditorLogic.test.ts index c68f8e3e8ccb..7e5fd6e9d3c4 100644 --- a/frontend/src/scenes/data-warehouse/editor/sqlEditorLogic.test.ts +++ b/frontend/src/scenes/data-warehouse/editor/sqlEditorLogic.test.ts @@ -2172,6 +2172,10 @@ describe('sqlEditorLogic', () => { { table: 'system_metrics', connectionId: undefined }, ]) + biLogic.actions.setDataSource({ table: 'hidden_table' }) + expect(biLogic.values.selectableDataSources[0]).toEqual({ table: 'hidden_table' }) + expect(biLogic.values.selectableDataSources).toHaveLength(biLogic.values.availableDataSources.length + 1) + biLogic.unmount() }) From 274fa83ab103a5a448a8914d4a711011c78eddee Mon Sep 17 00:00:00 2001 From: Frank Hamand Date: Thu, 1 Oct 2026 17:26:33 +0100 Subject: [PATCH 06/48] fix(metrics): remove explain metric button from viewer clause row The button opened the Fundamentals tab, which is behind the METRICS_FUNDAMENTALS flag. Without the flag, the scene fell back to the overview tab. Co-Authored-By: Claude Opus 5.5 Generated-By: PostHog Desktop Task-Id: 5a2b9f43-13fb-4aca-b9f3-a249aa554c8d --- .../frontend/components/MetricsClauseRow.tsx | 37 ++++--------------- 1 file changed, 7 insertions(+), 30 deletions(-) diff --git a/products/metrics/frontend/components/MetricsClauseRow.tsx b/products/metrics/frontend/components/MetricsClauseRow.tsx index bc108382ddce..9647a48fe5a2 100644 --- a/products/metrics/frontend/components/MetricsClauseRow.tsx +++ b/products/metrics/frontend/components/MetricsClauseRow.tsx @@ -1,6 +1,6 @@ import { useActions, useValues } from 'kea' -import { IconEllipsis, IconInfo } from '@posthog/icons' +import { IconEllipsis } from '@posthog/icons' import { LemonButton, LemonMenu, LemonSelect, LemonTag, Tooltip } from '@posthog/lemon-ui' import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types' @@ -8,10 +8,8 @@ import UniversalFilters from 'lib/components/UniversalFilters/UniversalFilters' import { FilterLogicalOperator, UniversalFiltersGroup } from '~/types' -import { metricsSceneLogic } from '../metricsSceneLogic' import { MetricNameFilter } from './MetricNameFilter' import { MetricsClauseFilterBar } from './MetricsClauseFilterBar' -import { metricsFundamentalsLogic } from './metricsFundamentalsLogic' import { MetricsGroupByButton } from './MetricsGroupByButton' import { MAX_CLAUSES, @@ -77,8 +75,6 @@ export function MetricsClauseRow({ const recommendedAggregation = clause.selectedMetricType ? RECOMMENDED_AGGREGATION_BY_TYPE[clause.selectedMetricType] : undefined - const { setActiveTab } = useActions(metricsSceneLogic) - const { explainMetric } = useActions(metricsFundamentalsLogic) return (
@@ -102,31 +98,12 @@ export function MetricsClauseRow({ )}
-
- - {clause.metricName && clause.selectedMetricType && ( - - } - onClick={() => { - explainMetric({ - metricName: clause.metricName, - aggregation: clause.aggregation, - }) - setActiveTab('fundamentals') - }} - data-attr="metrics-clause-explain" - /> - - )} -
+ {clause.selectedMetricType && recommendedAggregation && (clause.aggregation !== recommendedAggregation ? ( From 29596c086bfca658a5de29d1da54fa6a13d37cdc Mon Sep 17 00:00:00 2001 From: Frank Hamand Date: Thu, 1 Oct 2026 17:38:53 +0100 Subject: [PATCH 07/48] refactor(metrics): remove the fundamentals tab, explain API, and flag Removes the Fundamentals tab, its logic, the /metrics/explain/ action with its diagnostics and fundamentals modules, the metrics-fundamentals flag constants, and the MCP tool entry. Regenerates the API types. Co-Authored-By: Claude Opus 5.5 Generated-By: PostHog Desktop Task-Id: 5a2b9f43-13fb-4aca-b9f3-a249aa554c8d --- frontend/src/lib/constants.tsx | 1 - products/metrics/backend/diagnostics.py | 330 ---------------- products/metrics/backend/facade/api.py | 33 -- products/metrics/backend/facade/contracts.py | 64 ---- products/metrics/backend/fundamentals.py | 273 ------------- products/metrics/backend/presentation/api.py | 192 ---------- products/metrics/backend/tests/conftest.py | 5 +- products/metrics/backend/tests/test_api.py | 19 - .../metrics/backend/tests/test_diagnostics.py | 361 ------------------ .../backend/tests/test_fundamentals.py | 206 ---------- products/metrics/frontend/MetricsScene.tsx | 33 +- .../components/MetricsFundamentals.tsx | 235 ------------ .../metricsFundamentalsLogic.test.ts | 113 ------ .../components/metricsFundamentalsLogic.tsx | 174 --------- .../components/metricsHandoff.test.ts | 30 +- .../components/metricsOverviewLogic.test.ts | 1 - .../metrics/frontend/generated/api.schemas.ts | 273 ++----------- products/metrics/frontend/generated/api.ts | 24 -- .../metrics/frontend/generated/api.zod.ts | 99 ----- .../metrics/frontend/metricsSceneLogic.tsx | 4 +- products/metrics/mcp/tools.yaml | 3 - 21 files changed, 54 insertions(+), 2419 deletions(-) delete mode 100644 products/metrics/backend/diagnostics.py delete mode 100644 products/metrics/backend/fundamentals.py delete mode 100644 products/metrics/backend/tests/test_diagnostics.py delete mode 100644 products/metrics/backend/tests/test_fundamentals.py delete mode 100644 products/metrics/frontend/components/MetricsFundamentals.tsx delete mode 100644 products/metrics/frontend/components/metricsFundamentalsLogic.test.ts delete mode 100644 products/metrics/frontend/components/metricsFundamentalsLogic.tsx diff --git a/frontend/src/lib/constants.tsx b/frontend/src/lib/constants.tsx index edd01e3c6142..68f90a122461 100644 --- a/frontend/src/lib/constants.tsx +++ b/frontend/src/lib/constants.tsx @@ -417,7 +417,6 @@ export const FEATURE_FLAGS = { METRICS: 'metrics', // owner: #team-apm (@jonmcwest, @frankh) METRICS_DASHBOARD_PANELS: 'metrics-dashboard-panels', // owner: #team-apm — gates the stat/gauge/bargauge/table panel picker entries METRICS_ERROR_OVERLAYS: 'metrics-error-overlays', // owner: #team-apm — gates the error-spike overlay PoC on metrics charts - METRICS_FUNDAMENTALS: 'metrics-fundamentals', // owner: #team-apm (@jonmcwest, @frankh), gates the Fundamentals tab and the explain API behind it, which check the metrics viewer's own reductions ML_INFERENCE_DECISIONS: 'ml-inference-decisions', // owner: #team-ai-research, gates the decisions playground; the API checks the same flag server side NEW_TAB_PROJECT_EXPLORER: 'new-tab-project-explorer', // owner: #team-platform-ux NEW_TEAM_CORE_EVENTS: 'new-team-core-events', // owner: @jabahamondes #team-web-analytics diff --git a/products/metrics/backend/diagnostics.py b/products/metrics/backend/diagnostics.py deleted file mode 100644 index 0ee3419a0c49..000000000000 --- a/products/metrics/backend/diagnostics.py +++ /dev/null @@ -1,330 +0,0 @@ -"""Recompute one chart point from its raw samples, and show the working. - -A metrics chart is several reductions deep by the time it reaches a pixel, and -every one of them returns a plausible-looking number when it is wrong. The only -way to know a point is right is to take the bucket apart: which series reported, -what each one sent, what each collapsed to, and how those combined. - -`decompose_bucket` does that twice over. It reduces the raw samples in Python -through `fundamentals`, and separately asks `MetricQueryRunner` for the same -point. Two independent paths to one number means a disagreement is visible -rather than inferred — and the per-series breakdown alongside it shows which -step diverged. - -The Python side is deliberately not built from the HogQL builders. A reference -that shares its assumptions with the thing it checks agrees with it by -construction and catches nothing. -""" - -from __future__ import annotations - -import datetime as dt -from collections.abc import Sequence -from dataclasses import replace - -from posthog.hogql import ast -from posthog.hogql.parser import parse_select -from posthog.hogql.query import execute_hogql_query - -from posthog.clickhouse.client.connection import Workload -from posthog.models import Team - -from products.metrics.backend.facade.contracts import ( - MetricBucketDecomposition, - MetricFilter, - MetricSampleView, - MetricSeriesBreakdown, -) -from products.metrics.backend.fundamentals import Sample, TemporalReducer, apply_plan, plan_reduction, reduce_temporal -from products.metrics.backend.metric_query_runner import ( - _QUERY_SETTINGS, - _interval_step, - counter_lookback, - points_query, - series_labels_query, - series_scope_expr, - type_filter_expr, -) -from products.metrics.backend.metric_samples_query_runner import build_metric_query_runner -from products.metrics.backend.metrics4_samples import reads_metrics4_only - -# How much of a bucket the breakdown lists. Totals are computed over everything -# in the bucket; these only bound what gets rendered, and the decomposition says -# so when it has trimmed something. -DEFAULT_MAX_SERIES = 20 -DEFAULT_MAX_SAMPLES_PER_SERIES = 12 - -# A bucket on a wide metric can hold millions of rows. Reading every one to -# explain a single point is not worth the cluster time, so the raw read is -# bounded and reports when it hit the bound. -_MAX_ROWS_READ = 50000 - - -def _as_utc(timestamp: dt.datetime) -> dt.datetime: - """ClickHouse hands timestamps back naive; the bucket edges are aware.""" - return timestamp.replace(tzinfo=dt.UTC) if timestamp.tzinfo is None else timestamp.astimezone(dt.UTC) - - -def _raw_samples_query( - *, - metric_name: str, - date_from: dt.datetime, - bucket_end: dt.datetime, - filters: Sequence[MetricFilter], - metric_type: str | None, - timezone: str, -) -> ast.SelectQuery: - # The labels are joined on after the LIMIT so the row bound applies to the - # data points read, not to the join output. A series without a row yet - # keeps its samples and shows empty labels. - query = parse_select( - """ - SELECT - s.series_fingerprint, - s.service_name, - ser.attributes, - ser.resource_attributes, - s.metric_type, - s.aggregation_temporality, - s.timestamp, - s.value - FROM ( - SELECT - series_fingerprint, - service_name, - metric_type, - aggregation_temporality, - timestamp, - value - FROM {points} - ORDER BY timestamp ASC - LIMIT {row_limit} - ) AS s - LEFT JOIN {series_labels} AS ser ON s.series_fingerprint = ser.series_fingerprint - ORDER BY s.timestamp ASC - """, - placeholders={ - "points": points_query( - from_samples=reads_metrics4_only(date_from), - columns=( - "series_fingerprint", - "service_name", - "metric_type", - "aggregation_temporality", - "timestamp", - "value", - ), - metric_names=(metric_name,), - date_from=date_from, - date_to=bucket_end, - timezone=timezone, - row_filters=(series_scope_expr(metric_name, filters), type_filter_expr(metric_type)), - ), - "row_limit": ast.Constant(value=_MAX_ROWS_READ), - "series_labels": series_labels_query(metric_name), - }, - ) - assert isinstance(query, ast.SelectQuery) - return query - - -def _actual_value( - *, - team: Team, - metric_name: str, - aggregation: str, - bucket_start: dt.datetime, - bucket_end: dt.datetime, - interval: str, - filters: Sequence[MetricFilter], - metric_type: str | None, - quantile: float | None, -) -> float | None: - """What the product would plot for this point, through the real runner. - - The runner reaches back past `date_from` on its own for the counter - functions' predecessor sample, so this asks for exactly the one bucket the - decomposition is explaining. - """ - rows = build_metric_query_runner( - team=team, - metric_name=metric_name, - aggregation=aggregation, - date_from=bucket_start, - date_to=bucket_end, - interval=interval, - filters=filters, - metric_type=metric_type, - quantile=quantile, - ).run() - for row in rows: - if _as_utc(dt.datetime.fromisoformat(row["time"])) == bucket_start: - return row["value"] - return None - - -def decompose_bucket( - *, - team: Team, - metric_name: str, - aggregation: str, - bucket_start: dt.datetime, - interval: str, - filters: Sequence[MetricFilter] = (), - metric_type: str | None = None, - quantile: float | None = None, - max_series: int = DEFAULT_MAX_SERIES, - max_samples_per_series: int = DEFAULT_MAX_SAMPLES_PER_SERIES, -) -> MetricBucketDecomposition: - """Take one chart point apart into the series and samples behind it.""" - bucket_start = _as_utc(bucket_start) - step = _interval_step(interval) - bucket_end = bucket_start + step - # The counter functions diff against the newest sample before the bucket, - # the way the chart's window function does, so their raw read reaches back - # over exactly the runner's lookback. A shorter reach here would find a - # different predecessor and report a disagreement the chart does not have. - needs_boundary = aggregation in ("rate", "increase") - read_from = bucket_start - counter_lookback(interval) if needs_boundary else bucket_start - - response = execute_hogql_query( - query_type="MetricBucketDecomposition", - query=_raw_samples_query( - metric_name=metric_name, - date_from=read_from, - bucket_end=bucket_end, - filters=filters, - metric_type=metric_type, - timezone=team.timezone, - ), - team=team, - workload=Workload.LOGS, - settings=_QUERY_SETTINGS, - ) - rows = response.results or [] - rows_truncated = len(rows) >= _MAX_ROWS_READ - - # Group the raw rows into series by the fingerprint ingest assigned, which - # is the same identity the chart's window functions partition on. - grouped: dict[int, list[Sample]] = {} - predecessors: dict[int, Sample] = {} - identities: dict[int, tuple[str, dict[str, str], dict[str, str]]] = {} - resolved_type = metric_type or "" - temporality = "" - for ( - key, - service_name, - attributes, - resource_attributes, - row_metric_type, - row_temporality, - timestamp, - value, - ) in rows: - sample = Sample(timestamp=_as_utc(timestamp), value=float(value)) - if sample.timestamp < bucket_start: - # Only a series' newest pre-bucket reading matters: it is the - # baseline its first in-bucket diff runs against. - held = predecessors.get(key) - if held is None or sample.timestamp > held.timestamp: - predecessors[key] = sample - else: - grouped.setdefault(key, []).append(sample) - identities.setdefault(key, (service_name, dict(attributes or {}), dict(resource_attributes or {}))) - # A bucket normally holds one type and one temporality; when a name has - # been ingested as several, the first is enough to plan a reduction and - # the type check reports the blend separately. - resolved_type = resolved_type or row_metric_type - temporality = temporality or row_temporality - - plan = plan_reduction( - aggregation=aggregation, - metric_type=resolved_type, - temporality=temporality, - interval_seconds=step.total_seconds(), - ) - if quantile is not None: - plan = replace(plan, quantile=quantile) - - # Delta increments before the bucket belong to the previous point, so only - # the odometer-style reduction gets its baseline prepended. - if plan.temporal is TemporalReducer.INCREASE: - reduction_input = { - key: ([predecessors[key], *samples] if key in predecessors else samples) for key, samples in grouped.items() - } - else: - reduction_input = grouped - - reference_value = apply_plan(reduction_input, plan) - - # Largest contributors first — that is what someone reading a surprising - # total wants to see, and it makes the trimmed tail the least interesting part. - ordered_keys = sorted(grouped, key=lambda key: (-len(grouped[key]), identities[key][0])) - breakdown: list[MetricSeriesBreakdown] = [] - for key in ordered_keys[:max_series]: - samples = grouped[key] - service_name, labels, resource_labels = identities[key] - if plan.temporal is TemporalReducer.POOLED_SAMPLES: - series_value = None - else: - # Normalized the same way as the bucket's total, so the series - # a reader adds up still reach the number they are explaining. - reduced = reduce_temporal(reduction_input[key], plan.temporal) - series_value = None if reduced is None else reduced / plan.divisor - breakdown.append( - MetricSeriesBreakdown( - service_name=service_name, - labels=labels, - resource_labels=resource_labels, - samples=tuple( - MetricSampleView(time=sample.timestamp.isoformat(), value=sample.value) - for sample in samples[:max_samples_per_series] - ), - sample_count=len(samples), - samples_truncated=len(samples) > max_samples_per_series, - value=series_value, - ) - ) - - actual_value = _actual_value( - team=team, - metric_name=metric_name, - aggregation=aggregation, - bucket_start=bucket_start, - bucket_end=bucket_end, - interval=interval, - filters=filters, - metric_type=metric_type, - quantile=quantile, - ) - - return MetricBucketDecomposition( - metric_name=metric_name, - metric_type=resolved_type, - temporality=temporality, - aggregation=aggregation, - bucket_start=bucket_start.isoformat(), - interval=interval, - temporal_reducer=plan.temporal.value, - spatial_reducer=plan.spatial.value, - series=tuple(breakdown), - series_count=len(grouped), - sample_count=sum(len(samples) for samples in grouped.values()), - series_truncated=len(grouped) > max_series, - rows_truncated=rows_truncated, - reference_value=reference_value, - actual_value=actual_value, - # A truncated read means the reference covers only part of the bucket, - # so any verdict would be an artifact of the unequal inputs. - agrees=None if rows_truncated else _agrees(reference_value, actual_value), - ) - - -def _agrees(reference: float | None, actual: float | None) -> bool: - """Float reductions in ClickHouse and Python accumulate in different orders, - so exact equality would report noise as disagreement. The tolerance is far - tighter than any real reduction bug, which move totals by whole multiples.""" - if reference is None or actual is None: - return reference is None and actual is None - scale = max(abs(reference), abs(actual), 1.0) - return abs(reference - actual) <= 1e-9 * scale diff --git a/products/metrics/backend/facade/api.py b/products/metrics/backend/facade/api.py index 2365e90d6ca5..a6304e09fcd4 100644 --- a/products/metrics/backend/facade/api.py +++ b/products/metrics/backend/facade/api.py @@ -21,13 +21,11 @@ from products.error_tracking.backend.facade.api import list_spike_events from products.metrics.backend.anomaly import characterize_anomaly as _characterize_anomaly -from products.metrics.backend.diagnostics import decompose_bucket as _decompose_bucket from products.metrics.backend.facade.contracts import ( CompanionMetric, IncidentContext, InvestigationResult, MetricAnomalyReport, - MetricBucketDecomposition, MetricErrorSpike, MetricEventSample, MetricFilter, @@ -569,34 +567,3 @@ def investigate_incident(*, team: Team, context: IncidentContext) -> Investigati filters=filters, companions=context.companions, ) - - -def explain_metric_bucket( - *, - team: Team, - metric_name: str, - aggregation: str, - bucket_start: dt.datetime, - interval: str, - filters: Sequence[MetricFilter] = (), - metric_type: MetricType | None = None, - quantile: float | None = None, -) -> MetricBucketDecomposition: - """Take one chart point apart and show how it was built. - - Returns the series that reported in the bucket, the samples each sent, and - the two reductions that combined them, alongside both the value the product - would plot and the value recomputed independently from the raw samples. - Reading them side by side is what makes an aggregation bug visible instead - of merely plausible. The presentation layer surfaces `ValueError` as a 400. - """ - return _decompose_bucket( - team=team, - metric_name=metric_name, - aggregation=aggregation, - bucket_start=bucket_start, - interval=interval, - filters=filters, - metric_type=metric_type.value if metric_type is not None else None, - quantile=quantile, - ) diff --git a/products/metrics/backend/facade/contracts.py b/products/metrics/backend/facade/contracts.py index 38967707adde..fe3b1aba222b 100644 --- a/products/metrics/backend/facade/contracts.py +++ b/products/metrics/backend/facade/contracts.py @@ -37,12 +37,6 @@ # Staff-only while it is a proof of concept. METRICS_ERROR_OVERLAYS_FEATURE_FLAG = "metrics-error-overlays" -# Fundamentals recomputes a chart point from its raw samples so the viewer's own -# reductions can be checked. That makes it a tool for the people who build the -# viewer, not a feature for the teams on the alpha, so it needs a gate of its own -# on top of METRICS_FEATURE_FLAG. -METRICS_FUNDAMENTALS_FEATURE_FLAG = "metrics-fundamentals" - @dataclass(frozen=True, slots=True) class MetricFilter: @@ -370,61 +364,3 @@ class MetricsOverview: series: int lookback_seconds: int services: tuple[MetricsServiceOverview, ...] - - -@dataclass(frozen=True, slots=True) -class MetricSampleView: - """One raw reading, as it sits in storage before any reduction.""" - - time: str - value: float - - -@dataclass(frozen=True, slots=True) -class MetricSeriesBreakdown: - """One physical series inside a bucket, and the value it contributed. - - `samples` is trimmed for display; `sample_count` always reports how many - the series really sent, so a trimmed list can't be mistaken for a quiet one. - """ - - service_name: str - labels: dict[str, str] - resource_labels: dict[str, str] - samples: tuple[MetricSampleView, ...] - sample_count: int - samples_truncated: bool - # None when the aggregation has no per-series step, as percentiles do not: - # they read the pooled readings, so no single number is this series' - # contribution. - value: float | None - - -@dataclass(frozen=True, slots=True) -class MetricBucketDecomposition: - """One chart point taken apart into the series and samples behind it. - - `reference_value` is recomputed from the raw samples independently of the - query builders; `actual_value` is what the product would plot. `agrees` - compares them, and is the part worth reading first — a mismatch means one - of the two reductions is wrong, and the breakdown shows where they parted. - """ - - metric_name: str - metric_type: str - temporality: str - aggregation: str - bucket_start: str - interval: str - temporal_reducer: str - spatial_reducer: str - series: tuple[MetricSeriesBreakdown, ...] - series_count: int - sample_count: int - series_truncated: bool - rows_truncated: bool - reference_value: float | None - actual_value: float | None - # None when the raw read was truncated: the reference then covers only part - # of the bucket, so comparing it to the chart proves nothing either way. - agrees: bool | None diff --git a/products/metrics/backend/fundamentals.py b/products/metrics/backend/fundamentals.py deleted file mode 100644 index 5fa5753ce4b5..000000000000 --- a/products/metrics/backend/fundamentals.py +++ /dev/null @@ -1,273 +0,0 @@ -"""The reduction rules every metric aggregation has to follow, as data. - -A bucket is not a bag of numbers. It holds a set of *series*, and each series -holds a set of *samples*. Collapsing it to one number is therefore two ordered -steps, never one: - - value(bucket) = spatial( over each series: temporal(its samples) ) - -`plan_reduction` picks both steps from the metric's type and temporality, which -is the part that is easy to get wrong by hand: a gauge sample is a re-reading -(take the last), a cumulative counter sample is an odometer (diff it), and a -delta counter sample is itself an increment (add them up). Applying one reducer -to all three silently returns a number that tracks the scrape rate instead of -the data. - -The reducers here are deliberately pure and independent of the HogQL builders in -`metric_query_runner`, so they can serve as the reference a query result is -checked against rather than a second copy of the same assumptions. -""" - -from __future__ import annotations - -import datetime as dt -from collections.abc import Mapping, Sequence -from enum import StrEnum -from typing import TypeVar - -from posthog.dataclasses import frozen - -# What `p95` means when a caller doesn't spell the percentile out. -_DEFAULT_QUANTILE = 0.95 - -# The reducers never read a series key; it only has to identify the series. -K = TypeVar("K") - - -@frozen -class Sample: - """One raw reading of one series.""" - - timestamp: dt.datetime - value: float - - -class TemporalReducer(StrEnum): - """How one series' samples collapse to that series' value for the bucket.""" - - # No temporal step: every raw sample flows into the spatial reducer. This is - # the shape of the bug this module exists to catch, kept nameable so a - # decomposition can report it rather than only failing a check. - NONE = "none" - # Gauges under an instant aggregation: the bucket's value is the most - # recent reading, matching PromQL's instant vector. - LAST = "last" - # Gauges under an average: the readings inside the bucket are all real - # observations, so the series' value for the bucket is their mean. - AVG_OVER_TIME = "avg_over_time" - # Percentiles: there is no per-series step at all. A percentile describes a - # distribution, and collapsing each series first would compute a percentile - # of summaries, which is not a percentile of anything. Samples are deduped - # by timestamp and pooled instead. - POOLED_SAMPLES = "pooled_samples" - # Delta counters: each sample is an increment already. - SUM_OVER_TIME = "sum_over_time" - # Cumulative counters: diff consecutive readings, treating a drop as a restart. - INCREASE = "increase" - - -class SpatialReducer(StrEnum): - """How one value per series collapses to the bucket's number.""" - - SUM = "sum" - AVG = "avg" - MIN = "min" - MAX = "max" - QUANTILE = "quantile" - COUNT_SERIES = "count_series" - - -@frozen -class ReductionPlan: - temporal: TemporalReducer - spatial: SpatialReducer - quantile: float | None = None - # `rate` is an increase per second, so its bucket total is divided by the - # time that total accumulated over. Every other aggregation plots the total. - divisor: float = 1.0 - - -_SPATIAL_BY_AGGREGATION: dict[str, SpatialReducer] = { - "sum": SpatialReducer.SUM, - "avg": SpatialReducer.AVG, - "min": SpatialReducer.MIN, - "max": SpatialReducer.MAX, - "count": SpatialReducer.COUNT_SERIES, - "p95": SpatialReducer.QUANTILE, - "quantile": SpatialReducer.QUANTILE, - "rate": SpatialReducer.SUM, - "increase": SpatialReducer.SUM, -} - -_COUNTER_FUNCTIONS = frozenset({"rate", "increase"}) - - -def _is_delta(temporality: str) -> bool: - return temporality == "delta" - - -def _rate_divisor(aggregation: str, interval_seconds: float | None) -> float: - """How long the bucket's total accumulated over, for the aggregations that - plot a per-second figure rather than the total itself. - - Refusing to default the interval keeps a plan built without one from - quietly reporting an increase where a rate was asked for — off by the - bucket length, which is the whole difference between the two. - """ - if aggregation != "rate": - return 1.0 - if interval_seconds is None or interval_seconds <= 0: - raise ValueError("rate is a per-second figure, so it needs a positive interval_seconds") - return float(interval_seconds) - - -def plan_reduction( - *, - aggregation: str, - metric_type: str, - temporality: str = "", - interval_seconds: float | None = None, -) -> ReductionPlan: - """Pick the two reduction steps for one aggregation on one kind of metric. - - `temporality` is the OTel `aggregation_temporality` column; gauges leave it - empty. It matters even for the instant aggregations, because a delta sample - is an increment rather than a reading. - - `interval_seconds` is the bucket's width, which only `rate` needs. - """ - try: - spatial = _SPATIAL_BY_AGGREGATION[aggregation] - except KeyError: - raise ValueError(f"Unsupported aggregation: {aggregation!r}") - - quantile = _DEFAULT_QUANTILE if spatial == SpatialReducer.QUANTILE else None - divisor = _rate_divisor(aggregation, interval_seconds) - - if _is_delta(temporality): - # Delta samples are increments whatever the caller asked for, so summing - # them over the bucket is the only reduction that keeps the total whole. - return ReductionPlan( - temporal=TemporalReducer.SUM_OVER_TIME, spatial=spatial, quantile=quantile, divisor=divisor - ) - if aggregation in _COUNTER_FUNCTIONS: - return ReductionPlan(temporal=TemporalReducer.INCREASE, spatial=spatial, quantile=quantile, divisor=divisor) - if spatial == SpatialReducer.QUANTILE: - return ReductionPlan( - temporal=TemporalReducer.POOLED_SAMPLES, spatial=spatial, quantile=quantile, divisor=divisor - ) - if spatial == SpatialReducer.AVG: - return ReductionPlan( - temporal=TemporalReducer.AVG_OVER_TIME, spatial=spatial, quantile=quantile, divisor=divisor - ) - return ReductionPlan(temporal=TemporalReducer.LAST, spatial=spatial, quantile=quantile, divisor=divisor) - - -def _deduped_in_time_order(samples: Sequence[Sample]) -> list[Sample]: - """One reading per timestamp, oldest first. - - A series re-delivered by the collector arrives as two rows sharing a - timestamp. That is one observation, so anything that adds samples together - has to collapse it first or the total moves with delivery luck. - """ - by_timestamp: dict[dt.datetime, Sample] = {} - for sample in sorted(samples, key=lambda s: s.timestamp): - by_timestamp.setdefault(sample.timestamp, sample) - return list(by_timestamp.values()) - - -def reduce_temporal(samples: Sequence[Sample], reducer: TemporalReducer) -> float | None: - """Collapse one series' samples to that series' value for the bucket. - - Returns None when the value is unknowable: a lone cumulative reading has - no predecessor to diff against, and 0 would read as a flat counter. - """ - if reducer in (TemporalReducer.NONE, TemporalReducer.POOLED_SAMPLES): - raise ValueError(f"{reducer!r} has no single per-series value; apply it through a plan") - ordered = _deduped_in_time_order(samples) - if reducer == TemporalReducer.INCREASE: - # A reading below its predecessor means the counter restarted, and the - # post-restart reading is itself the increase. - if len(ordered) < 2: - return None - total = 0.0 - for previous, current in zip(ordered, ordered[1:]): - total += current.value - previous.value if current.value >= previous.value else current.value - return total - if not ordered: - return 0.0 - - if reducer == TemporalReducer.LAST: - return ordered[-1].value - if reducer == TemporalReducer.SUM_OVER_TIME: - return sum(sample.value for sample in ordered) - if reducer == TemporalReducer.AVG_OVER_TIME: - return sum(sample.value for sample in ordered) / len(ordered) - raise ValueError(f"Unsupported temporal reducer: {reducer!r}") - - -def _quantile(sorted_values: Sequence[float], quantile: float) -> float: - """Linear interpolation between the closest ranks.""" - if len(sorted_values) == 1: - return sorted_values[0] - position = quantile * (len(sorted_values) - 1) - lower_index = int(position) - upper_index = min(lower_index + 1, len(sorted_values) - 1) - weight = position - lower_index - return sorted_values[lower_index] * (1 - weight) + sorted_values[upper_index] * weight - - -def reduce_spatial(values: Sequence[float], reducer: SpatialReducer, *, quantile: float | None = None) -> float | None: - """Combine one value per series into the bucket's number. - - Returns None for an empty bucket, which consumers render as a gap rather - than as a zero. - """ - # An empty bucket has no value at all, including no series count. Returning - # 0 here would make every gap look like a real zero. - if not values: - return None - if reducer == SpatialReducer.COUNT_SERIES: - return float(len(values)) - - if reducer == SpatialReducer.SUM: - return sum(values) - if reducer == SpatialReducer.AVG: - return sum(values) / len(values) - if reducer == SpatialReducer.MIN: - return min(values) - if reducer == SpatialReducer.MAX: - return max(values) - if reducer == SpatialReducer.QUANTILE: - return _quantile(sorted(values), quantile if quantile is not None else _DEFAULT_QUANTILE) - raise ValueError(f"Unsupported spatial reducer: {reducer!r}") - - -def apply_plan(series_samples: Mapping[K, Sequence[Sample]], plan: ReductionPlan) -> float | None: - """Run both reduction steps over a bucket's series and return its number.""" - if plan.temporal == TemporalReducer.NONE: - per_series_values = [sample.value for samples in series_samples.values() for sample in samples] - elif plan.temporal == TemporalReducer.POOLED_SAMPLES: - per_series_values = [ - sample.value for samples in series_samples.values() for sample in _deduped_in_time_order(samples) - ] - else: - # An unknowable series value contributes nothing rather than a fake 0, - # and a bucket holding only unknowns has no value at all. - reduced = (reduce_temporal(samples, plan.temporal) for samples in series_samples.values() if samples) - per_series_values = [value for value in reduced if value is not None] - value = reduce_spatial(per_series_values, plan.spatial, quantile=plan.quantile) - # An empty bucket has no number, and normalizing None would invent one. - return value if value is None else value / plan.divisor - - -def is_duplicate_invariant(series_samples: Mapping[K, Sequence[Sample]], plan: ReductionPlan) -> bool: - """Whether re-delivering every sample leaves the bucket's number unchanged. - - Duplicating a scrape is the cheapest way to ask whether a reduction counts - series or counts rows, and it needs no reference implementation to compare - against — a correct plan simply returns the same number twice. - """ - baseline = apply_plan(series_samples, plan) - doubled = {key: [*samples, *samples] for key, samples in series_samples.items()} - return apply_plan(doubled, plan) == baseline diff --git a/products/metrics/backend/presentation/api.py b/products/metrics/backend/presentation/api.py index f2abb77dacdd..b9b8289faa1b 100644 --- a/products/metrics/backend/presentation/api.py +++ b/products/metrics/backend/presentation/api.py @@ -28,7 +28,6 @@ from products.metrics.backend.facade.api import ( characterize_metric_anomaly, - explain_metric_bucket, get_metrics_overview, list_metric_attribute_keys, list_metric_attribute_values, @@ -44,7 +43,6 @@ MAX_SPARKLINE_BATCH_SIZE, METRICS_ERROR_OVERLAYS_FEATURE_FLAG, METRICS_FEATURE_FLAG, - METRICS_FUNDAMENTALS_FEATURE_FLAG, MetricFilter, MetricGroupBy, MetricQueryClause, @@ -736,130 +734,6 @@ class _MetricErrorSpikesResponseSerializer(serializers.Serializer): ) -class _MetricExplainBodySerializer(serializers.Serializer): - metricName = serializers.CharField( - max_length=255, - help_text="Exact metric name whose bucket should be taken apart.", - ) - metricType = serializers.ChoiceField( - choices=[t.value for t in MetricType], - required=False, - allow_null=True, - help_text="Constrain the bucket to one metric type. A name can exist as several types; without this, rows of every type sharing the name are decomposed together.", - ) - aggregation = serializers.ChoiceField( - choices=["sum", "avg", "count", "min", "max", "p95", "rate", "increase", "histogram_quantile"], - default="sum", - help_text="The aggregation whose result should be explained. 'histogram_quantile' is rejected: it reduces bucket-count arrays rather than scalar samples, so there is no per-series value to lay out.", - ) - quantile = serializers.FloatField( - required=False, - allow_null=True, - min_value=0.0, - max_value=1.0, - help_text="Quantile in (0, 1) applied across series. Defaults to 0.95 for the 'p95' aggregation.", - ) - filters = _MetricFilterSerializer( - many=True, - required=False, - default=list, - help_text="Label predicates ANDed together, matching the chart the point came from.", - ) - bucketStart = serializers.DateTimeField( - help_text="Start of the bucket to explain, as returned in a query result's 'time'. ISO 8601.", - ) - interval = serializers.ChoiceField( - choices=MetricQueryInterval.choices, - help_text="Bucket size the point was plotted at. Must match the query that produced it, or the decomposition explains a different span.", - ) - - def validate(self, attrs: dict) -> dict: - if attrs.get("aggregation") == "histogram_quantile": - raise serializers.ValidationError( - "'histogram_quantile' cannot be decomposed: it reduces bucket-count arrays rather than scalar samples." - ) - return attrs - - -class _MetricExplainRequestSerializer(serializers.Serializer): - query = _MetricExplainBodySerializer(help_text="The chart point to take apart.") - - -class _MetricSampleViewSerializer(serializers.Serializer): - time = serializers.CharField(help_text="Sample timestamp, ISO 8601.") - value = serializers.FloatField(help_text="Raw stored reading, before any reduction.") - - -class _MetricSeriesBreakdownSerializer(serializers.Serializer): - service_name = serializers.CharField(help_text="Service that reported this series.") - labels = serializers.DictField( - child=serializers.CharField(), - help_text="Per-data-point attributes identifying the series.", - ) - resource_labels = serializers.DictField( - child=serializers.CharField(), - help_text="Resource attributes identifying the scrape target.", - ) - samples = _MetricSampleViewSerializer( - many=True, - help_text="The series' raw samples in this bucket, oldest first, trimmed for display.", - ) - sample_count = serializers.IntegerField( - help_text="How many samples the series actually sent, even when 'samples' was trimmed." - ) - samples_truncated = serializers.BooleanField(help_text="Whether 'samples' lists fewer samples than arrived.") - value = serializers.FloatField( - allow_null=True, - help_text="What this series contributed after the per-series reduction. Null for percentiles, which read the pooled readings and so have no single per-series contribution.", - ) - - -class _MetricBucketDecompositionSerializer(serializers.Serializer): - metric_name = serializers.CharField(help_text="Metric that was decomposed.") - metric_type = serializers.CharField(help_text="OTel metric type observed in the bucket.") - temporality = serializers.CharField( - allow_blank=True, - help_text="OTel aggregation temporality observed in the bucket ('cumulative', 'delta', or empty for gauges).", - ) - aggregation = serializers.CharField(help_text="Aggregation that was explained.") - bucket_start = serializers.CharField(help_text="Start of the explained bucket, ISO 8601.") - interval = serializers.CharField(help_text="Bucket size the point was plotted at.") - temporal_reducer = serializers.ChoiceField( - choices=["none", "last", "avg_over_time", "sum_over_time", "increase", "pooled_samples"], - help_text="How each series' samples were collapsed to one value: 'last' for an instant gauge reading, 'avg_over_time' for an average, 'sum_over_time' for delta counters, 'increase' for cumulative counters, and 'pooled_samples' for percentiles, which skip the per-series step entirely.", - ) - spatial_reducer = serializers.ChoiceField( - choices=["sum", "avg", "min", "max", "quantile", "count_series"], - help_text="How the per-series values were combined into the bucket's number.", - ) - series = _MetricSeriesBreakdownSerializer( - many=True, - help_text="The series behind the point, largest contributors first, trimmed for display.", - ) - series_count = serializers.IntegerField(help_text="How many series reported in the bucket.") - sample_count = serializers.IntegerField(help_text="How many raw samples the bucket held across all series.") - series_truncated = serializers.BooleanField(help_text="Whether 'series' lists fewer series than reported.") - rows_truncated = serializers.BooleanField( - help_text="Whether the bucket held more raw rows than the decomposition reads. Totals are computed only over the rows that were read." - ) - reference_value = serializers.FloatField( - allow_null=True, - help_text="The bucket's value recomputed from the raw samples, independently of the query builders. Null when no series reported.", - ) - actual_value = serializers.FloatField( - allow_null=True, - help_text="The value the product would plot for this point. Null when the query returned no row.", - ) - agrees = serializers.BooleanField( - allow_null=True, - help_text="Whether the two values match. False means one of the reductions is wrong, and the series breakdown shows where they parted. Null when the raw read was truncated, so the two are not comparable.", - ) - - -class _MetricExplainResponseSerializer(serializers.Serializer): - decomposition = _MetricBucketDecompositionSerializer(help_text="The bucket taken apart.") - - @extend_schema(tags=["metrics"]) class MetricsViewSet(TeamAndOrgViewSetMixin, viewsets.ViewSet): scope_object = "metrics" @@ -1177,72 +1051,6 @@ def error_spikes(self, request: Request, *args, **kwargs) -> Response: return Response({"results": [asdict(s) for s in spikes]}, status=status.HTTP_200_OK) - @extend_schema(request=_MetricExplainRequestSerializer, responses={200: _MetricExplainResponseSerializer}) - @action( - detail=False, - methods=["POST"], - required_scopes=["metrics:read"], - throttle_classes=[ClickHouseBurstRateThrottle, ClickHouseSustainedRateThrottle], - ) - def explain(self, request: Request, *args, **kwargs) -> Response: - """Take one chart point apart into the series and samples behind it, - and recompute it independently so the plotted number can be checked - rather than trusted.""" - # The class-level gate admits every team on the metrics alpha, which is wider - # than this action should be. Fundamentals is a correctness tool for the people - # who build the viewer, so it carries its own flag. Without this check the tab - # is hidden in the UI but the data behind it stays one POST away. - if not posthog_feature_flag_enabled( - METRICS_FUNDAMENTALS_FEATURE_FLAG, - str(cast(User, request.user).distinct_id), - organization_id=self.team.organization_id, - team_id=self.team.pk, - ): - raise PermissionDenied( - f"This action requires feature flag {METRICS_FUNDAMENTALS_FEATURE_FLAG!r} to be enabled for your organization." - ) - - tag_queries(product=Product.METRICS, feature=Feature.QUERY) - - body = _MetricExplainRequestSerializer(data=request.data) - body.is_valid(raise_exception=True) - query_data = body.validated_data["query"] - - filters = tuple( - MetricFilter(key=f["key"], op=FilterOp(f["op"]), value=f["value"], scope=AttributeScope(f["scope"])) - for f in query_data.get("filters") or [] - ) - try: - decomposition = explain_metric_bucket( - team=self.team, - metric_name=query_data["metricName"], - aggregation=query_data["aggregation"], - bucket_start=query_data["bucketStart"], - interval=query_data["interval"], - filters=filters, - metric_type=MetricType(query_data["metricType"]) if query_data.get("metricType") else None, - quantile=query_data.get("quantile"), - ) - except ValueError as exc: - raise ParseError(str(exc)) - - report_user_action( - request.user, - "metrics bucket explained", - { - "aggregation": decomposition.aggregation, - "metric_type": decomposition.metric_type, - "series_count": decomposition.series_count, - "agrees": decomposition.agrees, - }, - team=self.team, - request=request, - ) - - return Response( - _MetricExplainResponseSerializer({"decomposition": decomposition}).data, status=status.HTTP_200_OK - ) - @extend_schema(request=_MetricAnomalyRequestSerializer, responses={200: _MetricAnomalyReportSerializer}) @action( detail=False, diff --git a/products/metrics/backend/tests/conftest.py b/products/metrics/backend/tests/conftest.py index 477d97eedeb2..d40800c3c666 100644 --- a/products/metrics/backend/tests/conftest.py +++ b/products/metrics/backend/tests/conftest.py @@ -5,18 +5,15 @@ from posthog.api.snuffle_proxy import SNUFFLE_API_FEATURE_FLAG -from products.metrics.backend.facade.contracts import METRICS_FUNDAMENTALS_FEATURE_FLAG - @pytest.fixture(autouse=True) def enable_metrics_feature_flag() -> Iterator[None]: # Enable the flags needed by the metrics endpoint tests. # MetricsViewSet needs `metrics`. - # The explain action needs `METRICS_FUNDAMENTALS_FEATURE_FLAG`. # The Prometheus proxy needs `SNUFFLE_API_FEATURE_FLAG`. # Gate tests set their flag to False. def _feature_enabled(flag_key: str, *args: object, **kwargs: object) -> bool: - return flag_key in ("metrics", METRICS_FUNDAMENTALS_FEATURE_FLAG, SNUFFLE_API_FEATURE_FLAG) + return flag_key in ("metrics", SNUFFLE_API_FEATURE_FLAG) with patch("posthoganalytics.feature_enabled", side_effect=_feature_enabled): yield diff --git a/products/metrics/backend/tests/test_api.py b/products/metrics/backend/tests/test_api.py index 7b8b37f9d273..48b23e6e1fcd 100644 --- a/products/metrics/backend/tests/test_api.py +++ b/products/metrics/backend/tests/test_api.py @@ -23,7 +23,6 @@ ) from products.access_control.backend.models.access_control import AccessControl from products.error_tracking.backend.facade.testing import create_issue, create_spike_event -from products.metrics.backend.facade.contracts import METRICS_FUNDAMENTALS_FEATURE_FLAG def test_metrics_app_is_installed(): @@ -123,23 +122,6 @@ def test_metrics_flag_gates_the_api(self, _name: str, flag_enabled: bool, expect assert response.status_code == expected_status - @parameterized.expand( - [ - ("enabled", True, status.HTTP_400_BAD_REQUEST), - ("disabled", False, status.HTTP_403_FORBIDDEN), - ] - ) - def test_fundamentals_flag_gates_the_explain_action( - self, _name: str, fundamentals_enabled: bool, expected_status: int - ) -> None: - def feature_enabled(flag: str, *args: object, **kwargs: object) -> bool: - return fundamentals_enabled if flag == METRICS_FUNDAMENTALS_FEATURE_FLAG else True - - with patch("posthoganalytics.feature_enabled", side_effect=feature_enabled): - response = self.client.post(f"/api/projects/{self.team.id}/metrics/explain/", {}, format="json") - - assert response.status_code == expected_status - @pytest.mark.ee class TestMetricsAccessControl(APIBaseTest): @@ -196,7 +178,6 @@ def test_access_level_controls_metrics_queries( ("query", "POST", {}), ("samples", "POST", {}), ("error_spikes", "GET", {}), - ("explain", "POST", {}), ("characterize", "POST", {}), ] ) diff --git a/products/metrics/backend/tests/test_diagnostics.py b/products/metrics/backend/tests/test_diagnostics.py deleted file mode 100644 index 191b16bf2466..000000000000 --- a/products/metrics/backend/tests/test_diagnostics.py +++ /dev/null @@ -1,361 +0,0 @@ -import datetime as dt - -import pytest -from posthog.test.base import APIBaseTest, ClickhouseTestMixin -from unittest.mock import patch - -from products.metrics.backend import diagnostics -from products.metrics.backend.diagnostics import decompose_bucket -from products.metrics.backend.fundamentals import SpatialReducer, TemporalReducer -from products.metrics.backend.tests._seeder import seed_metric, truncate_metrics_tables - -BUCKET = dt.datetime(2026, 9, 15, 0, 0, 0, tzinfo=dt.UTC) - - -class TestBucketDecomposition(ClickhouseTestMixin, APIBaseTest): - """The decomposition recomputes a chart point from raw samples in Python and - reports it next to what the HogQL runner returned. The two disagreeing is the - signal — it means one of the reductions is wrong, and the breakdown shows which.""" - - def setUp(self): - super().setUp() - truncate_metrics_tables() - - def _seed_gauge_pair(self) -> None: - # Two pods reporting the same gauge, three scrapes each inside one bucket. - seed_metric( - team_id=self.team.pk, - metric_name="cache_size", - metric_type="gauge", - aggregation_temporality="", - labels={"pod": "a"}, - points=[ - (BUCKET, 5.0), - (BUCKET + dt.timedelta(seconds=60), 8.0), - (BUCKET + dt.timedelta(seconds=120), 11.0), - ], - ) - seed_metric( - team_id=self.team.pk, - metric_name="cache_size", - metric_type="gauge", - aggregation_temporality="", - labels={"pod": "b"}, - points=[ - (BUCKET, 20.0), - (BUCKET + dt.timedelta(seconds=60), 21.0), - (BUCKET + dt.timedelta(seconds=120), 22.0), - ], - ) - - def test_gauge_breakdown_reduces_each_series_to_its_latest_reading(self) -> None: - self._seed_gauge_pair() - - decomposition = decompose_bucket( - team=self.team, - metric_name="cache_size", - aggregation="sum", - bucket_start=BUCKET, - interval="minute_5", - ) - - assert decomposition.temporal_reducer == TemporalReducer.LAST - assert decomposition.spatial_reducer == SpatialReducer.SUM - assert decomposition.series_count == 2 - assert decomposition.sample_count == 6 - # 11 and 22 are the latest readings; the four earlier samples are re-readings. - contributions = [series.value for series in decomposition.series] - assert sorted(value for value in contributions if value is not None) == [11.0, 22.0] - assert None not in contributions - assert decomposition.reference_value == 33.0 - - def test_reports_disagreement_between_the_runner_and_the_reference(self) -> None: - """Whether these agree depends on the runner, which is the point: the check - holds a reduction the runner does not share, so a regression on either side - shows up as a disagreement rather than as a plausible-looking number.""" - self._seed_gauge_pair() - - decomposition = decompose_bucket( - team=self.team, - metric_name="cache_size", - aggregation="sum", - bucket_start=BUCKET, - interval="minute_5", - ) - - assert decomposition.actual_value is not None - assert decomposition.agrees == (decomposition.actual_value == decomposition.reference_value) - - def test_delta_counter_totals_every_increment_rather_than_the_last_one(self) -> None: - # Each delta sample IS an increment, so keeping only the newest would drop - # the rest of the bucket's traffic. - seed_metric( - team_id=self.team.pk, - metric_name="requests_total", - metric_type="sum", - aggregation_temporality="delta", - is_monotonic=True, - labels={"pod": "a"}, - points=[(BUCKET, 3.0), (BUCKET + dt.timedelta(seconds=60), 4.0), (BUCKET + dt.timedelta(seconds=120), 5.0)], - ) - - decomposition = decompose_bucket( - team=self.team, - metric_name="requests_total", - aggregation="sum", - bucket_start=BUCKET, - interval="minute_5", - ) - - assert decomposition.temporality == "delta" - assert decomposition.temporal_reducer == TemporalReducer.SUM_OVER_TIME - assert decomposition.reference_value == 12.0 - - def test_cumulative_counter_increase_diffs_within_the_series(self) -> None: - seed_metric( - team_id=self.team.pk, - metric_name="bytes_total", - metric_type="sum", - aggregation_temporality="cumulative", - is_monotonic=True, - labels={"pod": "a"}, - points=[ - (BUCKET, 100.0), - (BUCKET + dt.timedelta(seconds=60), 120.0), - (BUCKET + dt.timedelta(seconds=120), 5.0), - ], - ) - - decomposition = decompose_bucket( - team=self.team, - metric_name="bytes_total", - aggregation="increase", - bucket_start=BUCKET, - interval="minute_5", - ) - - assert decomposition.temporal_reducer == TemporalReducer.INCREASE - # +20, then a restart whose post-reset reading is itself the increase. - assert decomposition.reference_value == 25.0 - - def test_lone_cumulative_sample_has_no_increase_on_either_side(self) -> None: - seed_metric( - team_id=self.team.pk, - metric_name="bytes_total", - metric_type="sum", - aggregation_temporality="cumulative", - is_monotonic=True, - points=[(BUCKET, 100.0)], - ) - - decomposition = decompose_bucket( - team=self.team, - metric_name="bytes_total", - aggregation="increase", - bucket_start=BUCKET, - interval="minute_5", - ) - - # The sample's history is unknown, so both the reference and the chart - # return no value — a 0 on either side would fabricate a flat counter. - assert decomposition.reference_value is None - assert decomposition.actual_value is None - assert decomposition.agrees is True - - def test_empty_bucket_reports_no_series_rather_than_zero(self) -> None: - decomposition = decompose_bucket( - team=self.team, - metric_name="nothing_here", - aggregation="sum", - bucket_start=BUCKET, - interval="minute_5", - ) - - assert decomposition.series_count == 0 - assert decomposition.reference_value is None - - def test_truncation_is_reported_rather_than_silently_dropping_series(self) -> None: - for index in range(4): - seed_metric( - team_id=self.team.pk, - metric_name="wide_metric", - metric_type="gauge", - aggregation_temporality="", - labels={"pod": f"pod-{index}"}, - points=[(BUCKET, float(index))], - ) - - decomposition = decompose_bucket( - team=self.team, - metric_name="wide_metric", - aggregation="sum", - bucket_start=BUCKET, - interval="minute_5", - max_series=2, - ) - - # The totals stay whole; only the per-series listing is shortened. - assert decomposition.series_count == 4 - assert len(decomposition.series) == 2 - assert decomposition.series_truncated is True - assert decomposition.reference_value == 6.0 - - -class TestExplainEndpoint(ClickhouseTestMixin, APIBaseTest): - def setUp(self): - super().setUp() - truncate_metrics_tables() - - def test_explain_returns_the_series_behind_a_point(self) -> None: - seed_metric( - team_id=self.team.pk, - metric_name="cache_size", - metric_type="gauge", - aggregation_temporality="", - labels={"pod": "a"}, - points=[(BUCKET, 5.0), (BUCKET + dt.timedelta(seconds=60), 11.0)], - ) - - response = self.client.post( - f"/api/projects/{self.team.id}/metrics/explain/", - { - "query": { - "metricName": "cache_size", - "aggregation": "sum", - "bucketStart": BUCKET.isoformat(), - "interval": "minute_5", - } - }, - format="json", - ) - - assert response.status_code == 200, response.json() - decomposition = response.json()["decomposition"] - assert decomposition["temporal_reducer"] == "last" - assert decomposition["series_count"] == 1 - assert decomposition["reference_value"] == 11.0 - assert [sample["value"] for sample in decomposition["series"][0]["samples"]] == [5.0, 11.0] - - def test_rejects_an_interval_the_chart_could_not_have_used(self) -> None: - response = self.client.post( - f"/api/projects/{self.team.id}/metrics/explain/", - { - "query": { - "metricName": "cache_size", - "aggregation": "sum", - "bucketStart": BUCKET.isoformat(), - "interval": "fortnight", - } - }, - format="json", - ) - - assert response.status_code == 400 - - -class TestCounterBoundary(ClickhouseTestMixin, APIBaseTest): - def setUp(self): - super().setUp() - truncate_metrics_tables() - # The predecessor sample sits in the previous bucket; the chart's window - # function diffs across that edge, so the check has to as well. - seed_metric( - team_id=self.team.pk, - metric_name="bytes_total", - metric_type="sum", - aggregation_temporality="cumulative", - is_monotonic=True, - labels={"pod": "a"}, - points=[ - (BUCKET - dt.timedelta(seconds=60), 100.0), - (BUCKET + dt.timedelta(seconds=60), 120.0), - (BUCKET + dt.timedelta(seconds=120), 140.0), - ], - ) - - def test_increase_counts_the_rise_across_the_bucket_edge(self) -> None: - decomposition = decompose_bucket( - team=self.team, - metric_name="bytes_total", - aggregation="increase", - bucket_start=BUCKET, - interval="minute_5", - ) - - # 100 -> 120 -> 140: the bucket rose by 40, of which 20 crosses the - # edge. Read in isolation, both sides would drop that 20 and agree on - # a value the chart never plotted. - assert decomposition.reference_value == 40.0 - assert decomposition.actual_value == 40.0 - assert decomposition.agrees is True - - def test_agrees_when_the_predecessor_sits_further_back_than_one_bucket(self) -> None: - # A minute chart of a series scraped every few minutes: the reference - # reduction and the runner have to reach back over the same window, or - # one of them finds a predecessor the other doesn't and the tab reports - # a disagreement the chart never had. - seed_metric( - team_id=self.team.pk, - metric_name="packets_total", - metric_type="sum", - aggregation_temporality="cumulative", - is_monotonic=True, - points=[ - (BUCKET - dt.timedelta(minutes=3), 100.0), - (BUCKET + dt.timedelta(seconds=30), 120.0), - ], - ) - - decomposition = decompose_bucket( - team=self.team, - metric_name="packets_total", - aggregation="increase", - bucket_start=BUCKET, - interval="minute", - ) - - assert decomposition.reference_value == 20.0 - assert decomposition.actual_value == 20.0 - assert decomposition.agrees is True - - def test_rate_normalizes_the_boundary_increase_too(self) -> None: - decomposition = decompose_bucket( - team=self.team, - metric_name="bytes_total", - aggregation="rate", - bucket_start=BUCKET, - interval="minute_5", - ) - - assert decomposition.reference_value == pytest.approx(40.0 / 300.0) - assert decomposition.agrees is True - - -class TestTruncatedBucket(ClickhouseTestMixin, APIBaseTest): - def setUp(self): - super().setUp() - truncate_metrics_tables() - - def test_truncated_read_reports_not_comparable_instead_of_a_verdict(self) -> None: - seed_metric( - team_id=self.team.pk, - metric_name="cache_size", - metric_type="gauge", - aggregation_temporality="", - labels={"pod": "a"}, - points=[(BUCKET + dt.timedelta(seconds=10 * i), float(i)) for i in range(6)], - ) - - with patch.object(diagnostics, "_MAX_ROWS_READ", 5): - decomposition = decompose_bucket( - team=self.team, - metric_name="cache_size", - aggregation="sum", - bucket_start=BUCKET, - interval="minute_5", - ) - - # The reference saw 5 of 6 rows while the runner saw all of them, so - # any verdict would be an artifact of the unequal inputs. - assert decomposition.rows_truncated is True - assert decomposition.agrees is None diff --git a/products/metrics/backend/tests/test_fundamentals.py b/products/metrics/backend/tests/test_fundamentals.py deleted file mode 100644 index 5a2bdc5603e9..000000000000 --- a/products/metrics/backend/tests/test_fundamentals.py +++ /dev/null @@ -1,206 +0,0 @@ -import datetime as dt - -import pytest - -from parameterized import parameterized - -from products.metrics.backend.fundamentals import ( - ReductionPlan, - Sample, - SpatialReducer, - TemporalReducer, - apply_plan, - is_duplicate_invariant, - plan_reduction, - reduce_spatial, - reduce_temporal, -) - -BUCKET = dt.datetime(2026, 1, 1, 0, 0, 0, tzinfo=dt.UTC) - - -def _samples(*values: float, step_seconds: int = 30) -> list[Sample]: - return [Sample(timestamp=BUCKET + dt.timedelta(seconds=i * step_seconds), value=v) for i, v in enumerate(values)] - - -class TestPlanReduction: - @parameterized.expand( - [ - # A gauge sample is a re-reading, so the bucket's current value is the last one. - ("gauge_sum", "sum", "gauge", "", TemporalReducer.LAST, SpatialReducer.SUM, 1.0), - # A gauge that moves inside the bucket has a meaningful mean, so the - # per-series step averages over time rather than keeping one reading. - ("gauge_avg", "avg", "gauge", "", TemporalReducer.AVG_OVER_TIME, SpatialReducer.AVG, 1.0), - ("gauge_count", "count", "gauge", "", TemporalReducer.LAST, SpatialReducer.COUNT_SERIES, 1.0), - # A percentile describes a distribution, so it needs the readings - # themselves rather than one summary number per series. - ("gauge_p95", "p95", "gauge", "", TemporalReducer.POOLED_SAMPLES, SpatialReducer.QUANTILE, 1.0), - # A cumulative counter carries an absolute odometer reading. - ("cumulative_sum", "sum", "sum", "cumulative", TemporalReducer.LAST, SpatialReducer.SUM, 1.0), - ( - "cumulative_increase", - "increase", - "sum", - "cumulative", - TemporalReducer.INCREASE, - SpatialReducer.SUM, - 1.0, - ), - # Same two reduction steps as `increase`; only the divisor separates them. - ("cumulative_rate", "rate", "sum", "cumulative", TemporalReducer.INCREASE, SpatialReducer.SUM, 300.0), - # A delta sample IS an increment, so the bucket total is their sum. Taking the - # last sample here keeps one increment and discards the rest. - ("delta_sum", "sum", "sum", "delta", TemporalReducer.SUM_OVER_TIME, SpatialReducer.SUM, 1.0), - ("delta_increase", "increase", "sum", "delta", TemporalReducer.SUM_OVER_TIME, SpatialReducer.SUM, 1.0), - ("delta_rate", "rate", "sum", "delta", TemporalReducer.SUM_OVER_TIME, SpatialReducer.SUM, 300.0), - ] - ) - def test_plan_maps_type_and_temporality( - self, - _name: str, - aggregation: str, - metric_type: str, - temporality: str, - expected_temporal: TemporalReducer, - expected_spatial: SpatialReducer, - expected_divisor: float, - ) -> None: - plan = plan_reduction( - aggregation=aggregation, metric_type=metric_type, temporality=temporality, interval_seconds=300 - ) - assert plan.temporal == expected_temporal - assert plan.spatial == expected_spatial - assert plan.divisor == expected_divisor - - -class TestTemporalReduction: - def test_last_uses_latest_timestamp_not_largest_value(self) -> None: - # A falling gauge: the peak is stale, the current reading is the tail. - assert reduce_temporal(_samples(100, 50, 10), TemporalReducer.LAST) == 10 - - def test_sum_over_time_dedupes_duplicate_timestamps(self) -> None: - # Two rows at one timestamp are one increment delivered twice, not two increments. - duplicated = [*_samples(3, 4), Sample(timestamp=BUCKET, value=3)] - assert reduce_temporal(duplicated, TemporalReducer.SUM_OVER_TIME) == 7 - - def test_increase_corrects_counter_reset(self) -> None: - # 100 -> 120 is +20; the drop to 5 is a restart, so 5 itself is the increase; 5 -> 25 is +20. - assert reduce_temporal(_samples(100, 120, 5, 25), TemporalReducer.INCREASE) == 45 - - def test_increase_of_a_lone_sample_is_unknown_not_zero(self) -> None: - # One reading has no predecessor to diff against; 0 would read as "flat". - assert reduce_temporal(_samples(100), TemporalReducer.INCREASE) is None - - def test_avg_over_time_keeps_the_whole_bucket_not_just_the_tail(self) -> None: - # A queue that spiked to 240 and settled at 8 did not average 8. - assert reduce_temporal(_samples(6, 240, 5, 210, 7, 8), TemporalReducer.AVG_OVER_TIME) == pytest.approx( - 79.33, abs=0.01 - ) - - -class TestSpatialReduction: - @parameterized.expand( - [ - ("sum", SpatialReducer.SUM, 30.0), - ("avg", SpatialReducer.AVG, 10.0), - ("min", SpatialReducer.MIN, 5.0), - ("max", SpatialReducer.MAX, 15.0), - ("count_series", SpatialReducer.COUNT_SERIES, 3.0), - ] - ) - def test_combines_one_value_per_series(self, _name: str, reducer: SpatialReducer, expected: float) -> None: - assert reduce_spatial([10.0, 5.0, 15.0], reducer) == expected - - def test_quantile_runs_over_series_values(self) -> None: - assert reduce_spatial([1.0, 2.0, 3.0, 4.0], SpatialReducer.QUANTILE, quantile=0.5) == pytest.approx(2.5) - - @parameterized.expand([("sum", SpatialReducer.SUM), ("count_series", SpatialReducer.COUNT_SERIES)]) - def test_empty_bucket_has_no_value(self, _name: str, reducer: SpatialReducer) -> None: - # The runner returns no row for an empty bucket, so a reference that - # returned 0 here would report every empty bucket as a disagreement. - assert reduce_spatial([], reducer) is None - - def test_unknown_series_values_drop_out_rather_than_zeroing_the_bucket(self) -> None: - plan = plan_reduction(aggregation="increase", metric_type="sum", temporality="cumulative") - # A lone-sample series adds nothing to the total, and a bucket holding - # only such series has no value at all — mirroring the runner, which - # drops the bucket instead of plotting 0. - assert apply_plan({"a": _samples(100), "b": _samples(10, 25)}, plan) == 15.0 - assert apply_plan({"a": _samples(100)}, plan) is None - - -class TestPooledQuantile: - def test_percentile_reads_the_samples_rather_than_one_value_per_series(self) -> None: - # One series that swings inside the bucket still has a tail. - plan = plan_reduction(aggregation="p95", metric_type="gauge") - spiky = {"a": _samples(6, 240, 5, 210, 7, 8)} - assert apply_plan(spiky, plan) == pytest.approx(232.5) - - def test_percentile_pools_across_series(self) -> None: - plan = plan_reduction(aggregation="p95", metric_type="gauge") - pooled = apply_plan({"a": _samples(1, 2), "b": _samples(3, 4)}, plan) - assert pooled == pytest.approx(_quantile_of([1.0, 2.0, 3.0, 4.0])) - - -class TestRateNormalization: - @parameterized.expand( - [ - # A counter climbing 60 over a five-minute bucket is 0.2/s. - ("rate_is_per_second", "rate", 0.2), - ("increase_is_the_total", "increase", 60.0), - ] - ) - def test_only_rate_divides_by_the_bucket_length(self, _name: str, aggregation: str, expected: float) -> None: - # The runner divides a rate by the bucket length, so a reference that - # skips it disagrees with every correct rate chart by that length. - plan = plan_reduction( - aggregation=aggregation, metric_type="sum", temporality="cumulative", interval_seconds=300 - ) - assert apply_plan({"a": _samples(10, 70)}, plan) == pytest.approx(expected) - - def test_rate_refuses_to_plan_without_a_bucket_length(self) -> None: - # Defaulting the interval would silently plot an increase as a rate. - with pytest.raises(ValueError): - plan_reduction(aggregation="rate", metric_type="sum", temporality="cumulative") - - -def _quantile_of(values: list[float]) -> float: - position = 0.95 * (len(values) - 1) - lower = int(position) - upper = min(lower + 1, len(values) - 1) - weight = position - lower - return values[lower] * (1 - weight) + values[upper] * weight - - -class TestDuplicateSampleInvariance: - """Re-delivering a scrape must not move the number. This is the property that - both known aggregation bugs violate, in opposite directions.""" - - @parameterized.expand( - [ - ("gauge_sum", "sum", "gauge", ""), - ("gauge_avg", "avg", "gauge", ""), - ("gauge_p95", "p95", "gauge", ""), - ("gauge_count", "count", "gauge", ""), - ("delta_sum", "sum", "sum", "delta"), - ("cumulative_increase", "increase", "sum", "cumulative"), - ] - ) - def test_planned_reduction_is_invariant( - self, _name: str, aggregation: str, metric_type: str, temporality: str - ) -> None: - plan = plan_reduction(aggregation=aggregation, metric_type=metric_type, temporality=temporality) - series = {"a": _samples(5, 7, 9), "b": _samples(2, 4)} - assert is_duplicate_invariant(series, plan) is True - - @parameterized.expand( - [ - ("sum", SpatialReducer.SUM), - ("count_series", SpatialReducer.COUNT_SERIES), - ] - ) - def test_detects_a_plan_with_no_temporal_step(self, _name: str, spatial: SpatialReducer) -> None: - # NONE routes every raw sample to the spatial reducer, so the answer tracks the - # scrape rate rather than the data. - plan = ReductionPlan(temporal=TemporalReducer.NONE, spatial=spatial) - assert is_duplicate_invariant({"a": _samples(5, 5, 5)}, plan) is False diff --git a/products/metrics/frontend/MetricsScene.tsx b/products/metrics/frontend/MetricsScene.tsx index 970aee6728c6..0688dd06e09c 100644 --- a/products/metrics/frontend/MetricsScene.tsx +++ b/products/metrics/frontend/MetricsScene.tsx @@ -3,7 +3,6 @@ import posthog from 'posthog-js' import { LemonBanner, LemonButton, LemonTabs } from '@posthog/lemon-ui' -import { useFeatureFlag } from 'lib/hooks/useFeatureFlag' import { IconFeedback } from 'lib/lemon-ui/icons' import { getAccessControlDisabledReason } from 'lib/utils/accessControlUtils' import { sceneConfigurations } from 'scenes/scenes' @@ -18,15 +17,13 @@ import { AccessControlLevel, AccessControlResourceType } from '~/types' import { metricNamePickerLogic } from './components/metricNamePickerLogic' import { MetricsCatalog } from './components/MetricsCatalog' import { metricsCatalogLogic } from './components/metricsCatalogLogic' -import { MetricsFundamentals } from './components/MetricsFundamentals' -import { metricsFundamentalsLogic } from './components/metricsFundamentalsLogic' import { MetricsOverview } from './components/MetricsOverview' import { MetricsSqlEditor } from './components/MetricsSqlEditor' import { metricsUsageTrackingLogic } from './components/metricsUsageTrackingLogic' import { MetricsViewer } from './components/MetricsViewer' import { metricsEmptyState } from './emptyState/metricsEmptyState' import { metricsFeaturePreviewGate } from './featurePreviewGate' -import { DEFAULT_ACTIVE_TAB, MetricsSceneActiveTab, metricsSceneLogic } from './metricsSceneLogic' +import { MetricsSceneActiveTab, metricsSceneLogic } from './metricsSceneLogic' export const METRICS_LOGIC_KEY = 'metrics' @@ -37,7 +34,6 @@ const TABS: { key: MetricsSceneActiveTab; label: string; 'data-attr': string }[] { key: 'explore', label: 'Explore', 'data-attr': 'metrics-scene-tab-explore' }, { key: 'viewer', label: 'Viewer', 'data-attr': 'metrics-scene-tab-viewer' }, { key: 'sql', label: 'SQL', 'data-attr': 'metrics-scene-tab-sql' }, - { key: 'fundamentals', label: 'Fundamentals', 'data-attr': 'metrics-scene-tab-fundamentals' }, ] export const scene: SceneExport = { @@ -60,13 +56,6 @@ export function MetricsScene(): JSX.Element { const MetricsSceneContent = (): JSX.Element => { const { activeTab } = useValues(metricsSceneLogic) const { setActiveTab } = useActions(metricsSceneLogic) - // Fundamentals checks the viewer's own reductions against the raw samples, so it is - // built for the people who work on the viewer rather than for the teams on the alpha. - const fundamentalsEnabled = useFeatureFlag('METRICS_FUNDAMENTALS') - const visibleTabs = fundamentalsEnabled ? TABS : TABS.filter((tab) => tab.key !== 'fundamentals') - // A guessed ?activeTab=fundamentals must not render the tab either, so fall back to the - // default tab instead of leaving the scene with no visible content. - const effectiveTab = activeTab === 'fundamentals' && !fundamentalsEnabled ? DEFAULT_ACTIVE_TAB : activeTab const metricsViewerDisabledReason = getAccessControlDisabledReason( AccessControlResourceType.Metrics, AccessControlLevel.Viewer @@ -80,7 +69,6 @@ const MetricsSceneContent = (): JSX.Element => { explore: metricsViewerDisabledReason, viewer: metricsViewerDisabledReason, sql: metricsSqlDisabledReason, - fundamentals: metricsViewerDisabledReason, } // Scene-level so tab switches in both directions are captured; keeps the viewer // and samples logics (its connect targets) mounted across tab flips as a side effect. @@ -88,11 +76,9 @@ const MetricsSceneContent = (): JSX.Element => { // Prime the metric-name list here rather than inside MetricsViewer, so the fetch // races the has_metrics check instead of waiting on the setup prompt to resolve. useMountedLogic(metricNamePickerLogic) - // These two hold cross-tab state: a catalog card click preloads the viewer, and - // the viewer's explain button preloads fundamentals. Mounted here, a tab flip - // cannot unmount the logic and reset the handoff before the destination reads it. + // Holds cross-tab state: a catalog card click preloads the viewer. Mounted here, a tab + // flip cannot unmount the logic and reset the handoff before the destination reads it. useMountedLogic(metricsCatalogLogic) - useMountedLogic(metricsFundamentalsLogic) const onFeedbackClick = (): void => { posthog.displaySurvey(METRICS_FEEDBACK_SURVEY_ID) @@ -124,24 +110,23 @@ const MetricsSceneContent = (): JSX.Element => { Metrics is in alpha. Please share feedback on how to improve the product. - activeKey={effectiveTab} + activeKey={activeTab} onChange={(tab) => { if (!tabDisabledReasons[tab]) { setActiveTab(tab) } }} - tabs={visibleTabs.map((tab) => ({ + tabs={TABS.map((tab) => ({ ...tab, disabledReason: tabDisabledReasons[tab.key] ?? undefined, }))} sceneInset />
- {effectiveTab === 'overview' && } - {effectiveTab === 'explore' && } - {effectiveTab === 'viewer' && } - {effectiveTab === 'sql' && } - {effectiveTab === 'fundamentals' && } + {activeTab === 'overview' && } + {activeTab === 'explore' && } + {activeTab === 'viewer' && } + {activeTab === 'sql' && }
) diff --git a/products/metrics/frontend/components/MetricsFundamentals.tsx b/products/metrics/frontend/components/MetricsFundamentals.tsx deleted file mode 100644 index c80326c6fe29..000000000000 --- a/products/metrics/frontend/components/MetricsFundamentals.tsx +++ /dev/null @@ -1,235 +0,0 @@ -import { useActions, useValues } from 'kea' - -import { LemonBanner, LemonButton, LemonCollapse, LemonInput, LemonSelect, LemonTag } from '@posthog/lemon-ui' - -import type { _MetricBucketDecompositionApi, _MetricSeriesBreakdownApi } from '../generated/api.schemas' -import { metricsFundamentalsLogic } from './metricsFundamentalsLogic' -import type { MetricAggregation } from './metricsViewerLogic' - -// Each rule states what should happen, then the formula, then a worked example -// small enough to check by eye. Someone who has never thought about metric -// aggregation should be able to read one card and know what to look for. -const RULES: { key: string; title: string; should: string; formula: string; example: string }[] = [ - { - key: 'two-axes', - title: 'A bucket holds series, not numbers', - should: 'Every value is reduced twice. First each series is collapsed on its own, then those results are combined across series. Doing it in one step counts a series once per scrape, so the answer follows how often you collect rather than what you measured.', - formula: 'value = combine(over each series: collapse(its samples))', - example: - 'Two pods, three scrapes each, one bucket. That is 6 samples but only 2 series. A total should add 2 numbers, not 6.', - }, - { - key: 'typing', - title: 'The metric type decides how a series collapses', - should: 'A gauge sample is a fresh reading, so the newest one wins. A cumulative counter is an odometer, so you subtract consecutive readings. A delta counter reports an increment each time, so you add them up. One rule applied to all three is wrong for two of them.', - formula: 'gauge: last · cumulative counter: sum of diffs · delta counter: sum', - example: - 'A counter reading 100, 120, 5, 25 rose by 45. The drop to 5 is a restart, so 5 is itself an increase.', - }, - { - key: 'scrape-rate', - title: 'Collecting more often must not change the answer', - should: 'Sending the same reading twice is one observation delivered twice. If a value moves when a scrape is duplicated, dropped, or a bucket is still filling, the reduction is counting rows instead of series.', - formula: 'value(samples) == value(samples delivered twice)', - example: - 'A gauge scraped 10 times a bucket that reads 10x too high is the classic case. The multiplier moves with the scrape rate, so the chart jumps for no real reason.', - }, - { - key: 'staleness', - title: 'A series that goes quiet is not a zero', - should: 'A series that reports in one bucket and not the next has not dropped to zero, it just has not been heard from. Totals over sparsely reported metrics swing on how many series happened to report, which reads as a real change but is not one.', - formula: 'absent series should carry forward or drop out, never count as 0', - example: - 'A total across 300 series where only 5 report each minute swings by millions between buckets purely on who reported. The check above cannot catch this one. It reads a single bucket, so a series that never reported is invisible to it. Compare neighbouring buckets by hand.', - }, - { - key: 'ordering', - title: 'Percentiles and rates do not survive being averaged', - should: 'A percentile of percentiles is not a percentile, and a rate of summed counters misreads restarts. Rates reduce inside each series first, then combine. Percentiles go the other way and read every reading in the bucket, because collapsing a series to one number throws away the tail the percentile is asking about. The tradeoff is that a series collected more often contributes more readings to that tail.', - formula: 'sum(rate(x)), never rate(sum(x))', - example: - 'Host A serves 1,000 requests at p95 of 1ms, host B serves 10 at 2,000ms. Averaging the two p95s gives about 1,000ms. The real combined p95 is about 1ms.', - }, -] - -// These describe the reduction the check itself applied, which is only also -// what the chart did when the two agree. The wording says so either way. -const TEMPORAL_REDUCER_COPY: Record = { - last: 'took each series latest reading', - avg_over_time: 'averaged each series readings over the bucket', - sum_over_time: 'added up each series increments', - increase: 'measured how much each series rose', - pooled_samples: 'used every reading rather than one value per series', - none: 'did not reduce per series, so every raw sample counted', -} - -const SPATIAL_REDUCER_COPY: Record = { - sum: 'added the series together', - avg: 'averaged across series', - min: 'took the smallest series', - max: 'took the largest series', - quantile: 'took a percentile across series', - count_series: 'counted how many series reported', -} - -const AGGREGATION_OPTIONS: { value: MetricAggregation; label: string }[] = [ - { value: 'sum', label: 'Sum' }, - { value: 'avg', label: 'Average' }, - { value: 'count', label: 'Count' }, - { value: 'min', label: 'Min' }, - { value: 'max', label: 'Max' }, - { value: 'p95', label: 'p95' }, - { value: 'rate', label: 'Rate (/s)' }, - { value: 'increase', label: 'Increase' }, -] - -// Float reductions land on values like 79.33333333333333, which are unreadable -// next to each other and imply a precision the comparison does not use. -const formatValue = (value: number | null): string => - value === null ? 'no value' : Number(value.toPrecision(10)).toLocaleString('en-US', { maximumFractionDigits: 4 }) - -const SeriesRow = ({ series }: { series: _MetricSeriesBreakdownApi }): JSX.Element => { - const labelText = - Object.entries(series.labels) - .map(([key, value]) => `${key}=${value}`) - .join(' ') || 'no labels' - - return ( -
-
- - {series.service_name} {labelText} - - - {series.value === null ? `${series.sample_count} readings` : formatValue(series.value)} - -
-
- {series.samples.map((sample) => sample.value).join(', ')} - {series.samples_truncated && ` and ${series.sample_count - series.samples.length} more`} -
-
- ) -} - -const Decomposition = ({ decomposition }: { decomposition: _MetricBucketDecompositionApi }): JSX.Element => { - const temporal = TEMPORAL_REDUCER_COPY[decomposition.temporal_reducer] ?? decomposition.temporal_reducer - const spatial = SPATIAL_REDUCER_COPY[decomposition.spatial_reducer] ?? decomposition.spatial_reducer - - return ( -
- - {decomposition.agrees === null ? ( - <> - This bucket holds more raw samples than the check reads, so the recomputed value covers only - part of the data and proves nothing about the chart's {formatValue(decomposition.actual_value)}. - Narrow with a filter and check again. - - ) : decomposition.agrees ? ( - <> - The chart shows {formatValue(decomposition.actual_value)} for this bucket, and recomputing it - from the raw samples gives the same number. - - ) : ( - <> - The chart shows {formatValue(decomposition.actual_value)} for this bucket, but recomputing it - from the raw samples gives {formatValue(decomposition.reference_value)}. One of the two is - wrong. The series below show what the data actually contains. - - )} - - -
- {decomposition.metric_type || 'unknown type'} - {decomposition.temporality && {decomposition.temporality}} - {decomposition.series_count} series - {decomposition.sample_count} samples -
- -

- To get {formatValue(decomposition.reference_value)}, the check {temporal}, then {spatial}. - {decomposition.aggregation === 'rate' && - ' The result is divided by the bucket length, so it is per second.'} - {decomposition.agrees === false && ' The chart reached its number a different way.'} -

- -
-

Series in this bucket

- {decomposition.series.map((series, index) => ( - - ))} - {decomposition.series_truncated && ( -

- Showing the {decomposition.series.length} largest of {decomposition.series_count} series. The - totals above cover all of them. -

- )} -
-
- ) -} - -export function MetricsFundamentals(): JSX.Element { - const { metricName, aggregation, checkResult, checkResultLoading } = useValues(metricsFundamentalsLogic) - const { setMetricName, setAggregation, runCheck } = useActions(metricsFundamentalsLogic) - - return ( -
-

- What a metrics chart shows depends on how its numbers were combined, and a wrong combination still looks - like a normal chart. This page explains the rules a correct chart follows, then checks a real point - against them. -

- -
-

Check a point

-

- Pick a metric and we take its most recent complete 5 minute bucket apart. The value is recomputed - from the raw samples and compared against what the chart would draw. -

-
- runCheck()} - placeholder="Metric name" - className="w-80" - /> - - value={aggregation} - onChange={setAggregation} - options={AGGREGATION_OPTIONS} - /> - runCheck()} - loading={checkResultLoading} - disabledReason={!metricName ? 'Enter a metric name' : undefined} - > - Check - -
-
- - {checkResult && !checkResultLoading && } - -
-

The rules

- ({ - key: rule.key, - header: rule.title, - content: ( -
-

{rule.should}

- {rule.formula} -

{rule.example}

-
- ), - }))} - /> -
-
- ) -} diff --git a/products/metrics/frontend/components/metricsFundamentalsLogic.test.ts b/products/metrics/frontend/components/metricsFundamentalsLogic.test.ts deleted file mode 100644 index af32645d3d39..000000000000 --- a/products/metrics/frontend/components/metricsFundamentalsLogic.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { expectLogic } from 'kea-test-utils' - -import { initKeaTests } from '~/test/init' -import { AppContext } from '~/types' - -import { metricsExplainCreate, metricsQueryCreate } from 'products/metrics/frontend/generated/api' -import type { - _MetricBucketDecompositionApi, - _MetricQueryPointApi, - _MetricQueryResponseApi, -} from 'products/metrics/frontend/generated/api.schemas' - -import { metricsFundamentalsLogic } from './metricsFundamentalsLogic' - -jest.mock('products/metrics/frontend/generated/api', () => ({ - ...jest.requireActual('products/metrics/frontend/generated/api'), - metricsQueryCreate: jest.fn(), - metricsExplainCreate: jest.fn(), -})) - -const decomposition: _MetricBucketDecompositionApi = { - metric_name: 'cache_size', - metric_type: 'gauge', - temporality: '', - aggregation: 'sum', - bucket_start: '2026-01-01T00:05:00Z', - interval: '5m', - temporal_reducer: 'last', - spatial_reducer: 'sum', - series: [], - series_count: 0, - sample_count: 0, - series_truncated: false, - rows_truncated: false, - reference_value: 33, - actual_value: 33, - agrees: true, -} - -const queryResponse = (points: _MetricQueryPointApi[]): _MetricQueryResponseApi => ({ - results: [{ labels: {}, points }], -}) - -describe('metricsFundamentalsLogic', () => { - let logic: ReturnType - - beforeEach(() => { - window.POSTHOG_APP_CONTEXT = { current_project: { id: 997 } } as unknown as AppContext - initKeaTests() - logic = metricsFundamentalsLogic() - logic.mount() - jest.mocked(metricsExplainCreate).mockResolvedValue({ decomposition }) - }) - - afterEach(() => { - logic.unmount() - jest.clearAllMocks() - }) - - it('explains the newest bucket that has a value, not the newest bucket', async () => { - // The most recent bucket is usually still filling, so it comes back empty. - // Explaining it would decompose nothing while looking like it worked. - jest.mocked(metricsQueryCreate).mockResolvedValue( - queryResponse([ - { time: '2026-01-01T00:00:00Z', value: 1 }, - { time: '2026-01-01T00:05:00Z', value: 2 }, - { time: '2026-01-01T00:10:00Z', value: null }, - ]) - ) - - logic.actions.setMetricName('cache_size') - await expectLogic(logic, () => logic.actions.runCheck()).toFinishAllListeners() - - expect(jest.mocked(metricsExplainCreate).mock.calls[0][1].query.bucketStart).toEqual('2026-01-01T00:05:00Z') - }) - - it('drops the previous result when a new check starts', async () => { - // Otherwise the old metric's decomposition sits under the new metric's - // name while the new one loads, and reads as its answer. - jest.mocked(metricsQueryCreate).mockResolvedValue(queryResponse([{ time: '2026-01-01T00:00:00Z', value: 1 }])) - logic.actions.setMetricName('cache_size') - await expectLogic(logic, () => logic.actions.runCheck()).toFinishAllListeners() - expect(logic.values.checkResult).not.toBeNull() - - logic.actions.setMetricName('other_metric') - expectLogic(logic, () => logic.actions.runCheck()) - expect(logic.values.checkResult).toBeNull() - }) - - it('does not explain anything when the metric reported no values', async () => { - jest.mocked(metricsQueryCreate).mockResolvedValue(queryResponse([])) - - logic.actions.setMetricName('cache_size') - await expectLogic(logic, () => logic.actions.runCheck()).toFinishAllListeners() - - expect(metricsExplainCreate).not.toHaveBeenCalled() - expect(logic.values.checkResult).toBeNull() - }) - - it('explainMetric prefills the name and aggregation, then runs the check', async () => { - // The viewer's "explain this number" hands over a metric the user is already - // looking at, so the check must not make them retype it. - jest.mocked(metricsQueryCreate).mockResolvedValue(queryResponse([{ time: '2026-01-01T00:05:00Z', value: 2 }])) - - await expectLogic(logic, () => - logic.actions.explainMetric({ metricName: 'cache_size', aggregation: 'avg' }) - ).toFinishAllListeners() - - expect(logic.values.metricName).toBe('cache_size') - expect(logic.values.aggregation).toBe('avg') - expect(metricsExplainCreate).toHaveBeenCalled() - }) -}) diff --git a/products/metrics/frontend/components/metricsFundamentalsLogic.tsx b/products/metrics/frontend/components/metricsFundamentalsLogic.tsx deleted file mode 100644 index cfa3fba23f12..000000000000 --- a/products/metrics/frontend/components/metricsFundamentalsLogic.tsx +++ /dev/null @@ -1,174 +0,0 @@ -import { MakeLogicType, actions, kea, listeners, path, reducers, selectors } from 'kea' -import { loaders } from 'kea-loaders' - -import { lemonToast } from '@posthog/lemon-ui' - -import { dayjs } from 'lib/dayjs' - -import { metricsExplainCreate, metricsQueryCreate } from 'products/metrics/frontend/generated/api' -import type { _MetricBucketDecompositionApi } from 'products/metrics/frontend/generated/api.schemas' - -import type { MetricAggregation } from './metricsViewerLogic' - -// Bucket size the check runs at. Fixed rather than auto-picked, because a -// decomposition is only meaningful next to the interval it was computed for. -export const CHECK_INTERVAL = 'minute_5' - -// How far back to look for a bucket with data in it. The most recent bucket is -// usually still filling, so the check walks back to the last complete one. -const LOOKBACK_MINUTES = 60 - -export interface FundamentalsCheckResult { - decomposition: _MetricBucketDecompositionApi - checkedAt: string -} - -// Generated by kea-typegen. Update if you're an agent, ignore if you're human. -export interface metricsFundamentalsLogicValues { - aggregation: MetricAggregation - checkResult: FundamentalsCheckResult | null - checkResultLoading: boolean - metricName: string - projectId: string -} - -// Generated by kea-typegen. Update if you're an agent, ignore if you're human. -export interface metricsFundamentalsLogicActions { - explainMetric: (payload: { aggregation: MetricAggregation; metricName: string }) => { - aggregation: MetricAggregation - metricName: string - } - runCheck: () => { - value: true - } - runCheckFailure: ( - error: string, - errorObject?: any - ) => { - error: string - errorObject?: any - } - runCheckSuccess: ( - checkResult: { - checkedAt: string - decomposition: _MetricBucketDecompositionApi - } | null, - payload?: { - value: true - } - ) => { - checkResult: { - checkedAt: string - decomposition: _MetricBucketDecompositionApi - } | null - payload?: { - value: true - } - } - setAggregation: (aggregation: MetricAggregation) => { - aggregation: MetricAggregation - } - setMetricName: (metricName: string) => { - metricName: string - } -} - -export type metricsFundamentalsLogicType = MakeLogicType< - metricsFundamentalsLogicValues, - metricsFundamentalsLogicActions -> - -export const metricsFundamentalsLogic = kea([ - path(['products', 'metrics', 'frontend', 'components', 'metricsFundamentalsLogic']), - - actions({ - setMetricName: (metricName: string) => ({ metricName }), - setAggregation: (aggregation: MetricAggregation) => ({ aggregation }), - runCheck: true, - // The viewer's "explain this number" entry: prefill the metric the user is - // already looking at and run the check, so the handoff needs no retyping. - explainMetric: (payload: { metricName: string; aggregation: MetricAggregation }) => payload, - }), - - reducers({ - metricName: [ - '', - { - setMetricName: (_, { metricName }) => metricName, - explainMetric: (_, { metricName }) => metricName, - }, - ], - aggregation: [ - 'sum' as MetricAggregation, - { - setAggregation: (_, { aggregation }) => aggregation, - explainMetric: (_, { aggregation }) => aggregation, - }, - ], - // Drop the previous decomposition the moment a new check starts. The - // loading flag alone leaves a frame where the old answer is still on - // screen under the new metric name, which reads as the new answer. - checkResult: [null as FundamentalsCheckResult | null, { runCheck: () => null }], - }), - - loaders(({ values }) => ({ - checkResult: [ - null as FundamentalsCheckResult | null, - { - runCheck: async (_, breakpoint) => { - if (!values.metricName) { - return null - } - await breakpoint(300) - - const dateTo = dayjs().startOf('minute') - const dateFrom = dateTo.subtract(LOOKBACK_MINUTES, 'minute') - - // Ask the chart what it would plot first, so the bucket being - // explained is one the product actually drew rather than one - // picked blind — an empty bucket explains nothing. - const series = await metricsQueryCreate(values.projectId, { - query: { - metricName: values.metricName, - aggregation: values.aggregation, - interval: CHECK_INTERVAL, - dateFrom: dateFrom.toISOString(), - dateTo: dateTo.toISOString(), - }, - }) - breakpoint() - - const points = series.results?.[0]?.points ?? [] - const latest = [...points].reverse().find((point) => point.value !== null) - if (!latest) { - lemonToast.info(`No data for ${values.metricName} in the last hour`) - return null - } - - const response = await metricsExplainCreate(values.projectId, { - query: { - metricName: values.metricName, - aggregation: values.aggregation, - bucketStart: latest.time, - interval: CHECK_INTERVAL, - }, - }) - breakpoint() - - return { decomposition: response.decomposition, checkedAt: dayjs().toISOString() } - }, - }, - ], - })), - - selectors({ - projectId: [() => [], () => window.POSTHOG_APP_CONTEXT?.current_project?.id?.toString() ?? ''], - }), - - listeners(({ actions }) => ({ - explainMetric: () => { - // Reducers have already prefilled the name and aggregation; run the check. - actions.runCheck() - }, - })), -]) diff --git a/products/metrics/frontend/components/metricsHandoff.test.ts b/products/metrics/frontend/components/metricsHandoff.test.ts index 72222257fccc..66d788767cd3 100644 --- a/products/metrics/frontend/components/metricsHandoff.test.ts +++ b/products/metrics/frontend/components/metricsHandoff.test.ts @@ -5,19 +5,16 @@ import { AccessControlLevel, AccessControlResourceType, AppContext } from '~/typ import { metricsNamesRetrieve, metricsValuesRetrieve } from '../generated/api' import { metricsCatalogLogic } from './metricsCatalogLogic' -import { metricsFundamentalsLogic } from './metricsFundamentalsLogic' jest.mock('../generated/api', () => ({ ...jest.requireActual('../generated/api'), metricsNamesRetrieve: jest.fn(), metricsValuesRetrieve: jest.fn(), metricsQueryCreate: jest.fn(), - metricsExplainCreate: jest.fn(), })) -// The catalog and fundamentals logics are keyed (global) logics whose state must -// survive a tab flip: a card click preloads the viewer, and the viewer's explain -// button preloads fundamentals. The scene mounts both, so unmounting the tab +// The catalog logic is a keyed (global) logic whose state must survive a tab flip: +// a card click preloads the viewer. The scene mounts it, so unmounting the tab // component that also subscribes must not reset the preloaded state. describe('metrics cross-tab handoffs', () => { beforeEach(() => { @@ -32,29 +29,6 @@ describe('metrics cross-tab handoffs', () => { jest.mocked(metricsNamesRetrieve).mockResolvedValue({ results: [] } as any) }) - it('explainMetric state survives the viewer tab unmounting', async () => { - // Stand-in for the scene-level mount that keeps the logic alive across tabs. - const sceneHold = metricsFundamentalsLogic() - sceneHold.mount() - // Stand-in for the MetricsClauseRow subscription inside the viewer tab. - const viewerHold = metricsFundamentalsLogic() - viewerHold.mount() - - await expectLogic(sceneHold, () => - sceneHold.actions.explainMetric({ metricName: 'cache_size', aggregation: 'avg' }) - ).toFinishAllListeners() - expect(sceneHold.values.metricName).toBe('cache_size') - - // The viewer tab unmounts while fundamentals renders. With only the tab - // holding a subscription, kea would unmount the logic and drop the - // prefill; the scene hold must keep it alive. - viewerHold.unmount() - - expect(sceneHold.values.metricName).toBe('cache_size') - expect(sceneHold.values.aggregation).toBe('avg') - sceneHold.unmount() - }) - it('a loaded catalog survives the explore tab unmounting', async () => { const items = [{ name: 'jobs.processed', metric_type: 'sum' }] jest.mocked(metricsNamesRetrieve).mockResolvedValue({ results: items } as any) diff --git a/products/metrics/frontend/components/metricsOverviewLogic.test.ts b/products/metrics/frontend/components/metricsOverviewLogic.test.ts index baeccfeb8994..bc8cdd38ce12 100644 --- a/products/metrics/frontend/components/metricsOverviewLogic.test.ts +++ b/products/metrics/frontend/components/metricsOverviewLogic.test.ts @@ -14,7 +14,6 @@ jest.mock('../generated/api', () => ({ metricsAttributeValuesRetrieve: jest.fn(), metricsAttributesRetrieve: jest.fn(), metricsCharacterizeCreate: jest.fn(), - metricsExplainCreate: jest.fn(), metricsHasMetricsRetrieve: jest.fn(), metricsOverviewRetrieve: jest.fn(), metricsQueryCreate: jest.fn(), diff --git a/products/metrics/frontend/generated/api.schemas.ts b/products/metrics/frontend/generated/api.schemas.ts index d5a8d2ad8d51..0b3229b2dad9 100644 --- a/products/metrics/frontend/generated/api.schemas.ts +++ b/products/metrics/frontend/generated/api.schemas.ts @@ -295,239 +295,6 @@ export interface _MetricErrorSpikesResponseApi { results: _MetricErrorSpikeApi[] } -/** - * * `gauge` - gauge - * * `sum` - sum - * * `histogram` - histogram - * * `exponential_histogram` - exponential_histogram - * * `summary` - summary - */ -export type OtelMetricTypeEnumApi = (typeof OtelMetricTypeEnumApi)[keyof typeof OtelMetricTypeEnumApi] - -export const OtelMetricTypeEnumApi = { - Gauge: 'gauge', - Sum: 'sum', - Histogram: 'histogram', - ExponentialHistogram: 'exponential_histogram', - Summary: 'summary', -} as const - -/** - * * `second` - second - * * `minute` - minute - * * `minute_5` - minute_5 - * * `minute_15` - minute_15 - * * `hour` - hour - * * `hour_6` - hour_6 - * * `day` - day - * * `week` - week - */ -export type MetricQueryIntervalEnumApi = (typeof MetricQueryIntervalEnumApi)[keyof typeof MetricQueryIntervalEnumApi] - -export const MetricQueryIntervalEnumApi = { - Second: 'second', - Minute: 'minute', - Minute5: 'minute_5', - Minute15: 'minute_15', - Hour: 'hour', - Hour6: 'hour_6', - Day: 'day', - Week: 'week', -} as const - -export interface _MetricExplainBodyApi { - /** - * Exact metric name whose bucket should be taken apart. - * @maxLength 255 - */ - metricName: string - /** Constrain the bucket to one metric type. A name can exist as several types; without this, rows of every type sharing the name are decomposed together. - * - * * `gauge` - gauge - * * `sum` - sum - * * `histogram` - histogram - * * `exponential_histogram` - exponential_histogram - * * `summary` - summary */ - metricType?: OtelMetricTypeEnumApi | null - /** The aggregation whose result should be explained. 'histogram_quantile' is rejected: it reduces bucket-count arrays rather than scalar samples, so there is no per-series value to lay out. - * - * * `sum` - sum - * * `avg` - avg - * * `count` - count - * * `min` - min - * * `max` - max - * * `p95` - p95 - * * `rate` - rate - * * `increase` - increase - * * `histogram_quantile` - histogram_quantile */ - aggregation?: AggregationEnumApi - /** - * Quantile in (0, 1) applied across series. Defaults to 0.95 for the 'p95' aggregation. - * @minimum 0 - * @maximum 1 - * @nullable - */ - quantile?: number | null - /** Label predicates ANDed together, matching the chart the point came from. */ - filters?: _MetricFilterApi[] - /** Start of the bucket to explain, as returned in a query result's 'time'. ISO 8601. */ - bucketStart: string - /** Bucket size the point was plotted at. Must match the query that produced it, or the decomposition explains a different span. - * - * * `second` - second - * * `minute` - minute - * * `minute_5` - minute_5 - * * `minute_15` - minute_15 - * * `hour` - hour - * * `hour_6` - hour_6 - * * `day` - day - * * `week` - week */ - interval: MetricQueryIntervalEnumApi -} - -export interface _MetricExplainRequestApi { - /** The chart point to take apart. */ - query: _MetricExplainBodyApi -} - -/** - * * `none` - none - * * `last` - last - * * `avg_over_time` - avg_over_time - * * `sum_over_time` - sum_over_time - * * `increase` - increase - * * `pooled_samples` - pooled_samples - */ -export type TemporalReducerEnumApi = (typeof TemporalReducerEnumApi)[keyof typeof TemporalReducerEnumApi] - -export const TemporalReducerEnumApi = { - None: 'none', - Last: 'last', - AvgOverTime: 'avg_over_time', - SumOverTime: 'sum_over_time', - Increase: 'increase', - PooledSamples: 'pooled_samples', -} as const - -/** - * * `sum` - sum - * * `avg` - avg - * * `min` - min - * * `max` - max - * * `quantile` - quantile - * * `count_series` - count_series - */ -export type SpatialReducerEnumApi = (typeof SpatialReducerEnumApi)[keyof typeof SpatialReducerEnumApi] - -export const SpatialReducerEnumApi = { - Sum: 'sum', - Avg: 'avg', - Min: 'min', - Max: 'max', - Quantile: 'quantile', - CountSeries: 'count_series', -} as const - -export interface _MetricSampleViewApi { - /** Sample timestamp, ISO 8601. */ - time: string - /** Raw stored reading, before any reduction. */ - value: number -} - -/** - * Per-data-point attributes identifying the series. - */ -export type _MetricSeriesBreakdownApiLabels = { [key: string]: string } - -/** - * Resource attributes identifying the scrape target. - */ -export type _MetricSeriesBreakdownApiResourceLabels = { [key: string]: string } - -export interface _MetricSeriesBreakdownApi { - /** Service that reported this series. */ - service_name: string - /** Per-data-point attributes identifying the series. */ - labels: _MetricSeriesBreakdownApiLabels - /** Resource attributes identifying the scrape target. */ - resource_labels: _MetricSeriesBreakdownApiResourceLabels - /** The series' raw samples in this bucket, oldest first, trimmed for display. */ - samples: _MetricSampleViewApi[] - /** How many samples the series actually sent, even when 'samples' was trimmed. */ - sample_count: number - /** Whether 'samples' lists fewer samples than arrived. */ - samples_truncated: boolean - /** - * What this series contributed after the per-series reduction. Null for percentiles, which read the pooled readings and so have no single per-series contribution. - * @nullable - */ - value: number | null -} - -export interface _MetricBucketDecompositionApi { - /** Metric that was decomposed. */ - metric_name: string - /** OTel metric type observed in the bucket. */ - metric_type: string - /** OTel aggregation temporality observed in the bucket ('cumulative', 'delta', or empty for gauges). */ - temporality: string - /** Aggregation that was explained. */ - aggregation: string - /** Start of the explained bucket, ISO 8601. */ - bucket_start: string - /** Bucket size the point was plotted at. */ - interval: string - /** How each series' samples were collapsed to one value: 'last' for an instant gauge reading, 'avg_over_time' for an average, 'sum_over_time' for delta counters, 'increase' for cumulative counters, and 'pooled_samples' for percentiles, which skip the per-series step entirely. - * - * * `none` - none - * * `last` - last - * * `avg_over_time` - avg_over_time - * * `sum_over_time` - sum_over_time - * * `increase` - increase - * * `pooled_samples` - pooled_samples */ - temporal_reducer: TemporalReducerEnumApi - /** How the per-series values were combined into the bucket's number. - * - * * `sum` - sum - * * `avg` - avg - * * `min` - min - * * `max` - max - * * `quantile` - quantile - * * `count_series` - count_series */ - spatial_reducer: SpatialReducerEnumApi - /** The series behind the point, largest contributors first, trimmed for display. */ - series: _MetricSeriesBreakdownApi[] - /** How many series reported in the bucket. */ - series_count: number - /** How many raw samples the bucket held across all series. */ - sample_count: number - /** Whether 'series' lists fewer series than reported. */ - series_truncated: boolean - /** Whether the bucket held more raw rows than the decomposition reads. Totals are computed only over the rows that were read. */ - rows_truncated: boolean - /** - * The bucket's value recomputed from the raw samples, independently of the query builders. Null when no series reported. - * @nullable - */ - reference_value: number | null - /** - * The value the product would plot for this point. Null when the query returned no row. - * @nullable - */ - actual_value: number | null - /** - * Whether the two values match. False means one of the reductions is wrong, and the series breakdown shows where they parted. Null when the raw read was truncated, so the two are not comparable. - * @nullable - */ - agrees: boolean | null -} - -export interface _MetricExplainResponseApi { - /** The bucket taken apart. */ - decomposition: _MetricBucketDecompositionApi -} - export interface _HasMetricsResponseApi { /** Whether the team has ingested any metrics. */ hasMetrics: boolean @@ -572,6 +339,23 @@ export interface _MetricsOverviewResponseApi { services: _MetricsOverviewServiceApi[] } +/** + * * `gauge` - gauge + * * `sum` - sum + * * `histogram` - histogram + * * `exponential_histogram` - exponential_histogram + * * `summary` - summary + */ +export type OtelMetricTypeEnumApi = (typeof OtelMetricTypeEnumApi)[keyof typeof OtelMetricTypeEnumApi] + +export const OtelMetricTypeEnumApi = { + Gauge: 'gauge', + Sum: 'sum', + Histogram: 'histogram', + ExponentialHistogram: 'exponential_histogram', + Summary: 'summary', +} as const + export interface _MetricGroupByApi { /** * Attribute name to split series by (e.g. 'k8s.pod.name', 'env'). @@ -586,6 +370,29 @@ export interface _MetricGroupByApi { scope?: MetricAttributeScopeEnumApi } +/** + * * `second` - second + * * `minute` - minute + * * `minute_5` - minute_5 + * * `minute_15` - minute_15 + * * `hour` - hour + * * `hour_6` - hour_6 + * * `day` - day + * * `week` - week + */ +export type MetricQueryIntervalEnumApi = (typeof MetricQueryIntervalEnumApi)[keyof typeof MetricQueryIntervalEnumApi] + +export const MetricQueryIntervalEnumApi = { + Second: 'second', + Minute: 'minute', + Minute5: 'minute_5', + Minute15: 'minute_15', + Hour: 'hour', + Hour6: 'hour_6', + Day: 'day', + Week: 'week', +} as const + export interface _MetricClauseApi { /** * Clause name a formula refers to (e.g. 'a'). diff --git a/products/metrics/frontend/generated/api.ts b/products/metrics/frontend/generated/api.ts index 411333ea8d95..db916a058a92 100644 --- a/products/metrics/frontend/generated/api.ts +++ b/products/metrics/frontend/generated/api.ts @@ -23,8 +23,6 @@ import type { _MetricAttributeValuesResponseApi, _MetricCatalogValuesParamsApi, _MetricErrorSpikesResponseApi, - _MetricExplainRequestApi, - _MetricExplainResponseApi, _MetricNamesResponseApi, _MetricPickerNamesResponseApi, _MetricQueryRequestApi, @@ -194,28 +192,6 @@ export const metricsErrorSpikesRetrieve = async ( }) } -export const getMetricsExplainCreateUrl = (projectId: string) => { - return `/api/projects/${projectId}/metrics/explain/` -} - -/** - * Take one chart point apart into the series and samples behind it, - * and recompute it independently so the plotted number can be checked - * rather than trusted. - */ -export const metricsExplainCreate = async ( - projectId: string, - _metricExplainRequestApi: _MetricExplainRequestApi, - options?: RequestInit -): Promise<_MetricExplainResponseApi> => { - return apiMutator<_MetricExplainResponseApi>(getMetricsExplainCreateUrl(projectId), { - ...options, - method: 'POST', - headers: { 'Content-Type': 'application/json', ...options?.headers }, - body: JSON.stringify(_metricExplainRequestApi), - }) -} - export const getMetricsHasMetricsRetrieveUrl = (projectId: string) => { return `/api/projects/${projectId}/metrics/has_metrics/` } diff --git a/products/metrics/frontend/generated/api.zod.ts b/products/metrics/frontend/generated/api.zod.ts index 0fe892683a90..93bbc160fc82 100644 --- a/products/metrics/frontend/generated/api.zod.ts +++ b/products/metrics/frontend/generated/api.zod.ts @@ -114,105 +114,6 @@ export const MetricsCharacterizeCreateBody = /* @__PURE__ */ zod.object({ .describe('The anomaly characterization to run.'), }) -/** - * Take one chart point apart into the series and samples behind it, - * and recompute it independently so the plotted number can be checked - * rather than trusted. - */ -export const metricsExplainCreateBodyQueryOneMetricNameMax = 255 - -export const metricsExplainCreateBodyQueryOneAggregationDefault = `sum` -export const metricsExplainCreateBodyQueryOneQuantileMin = 0 -export const metricsExplainCreateBodyQueryOneQuantileMax = 1 - -export const metricsExplainCreateBodyQueryOneFiltersItemKeyMax = 255 - -export const metricsExplainCreateBodyQueryOneFiltersItemOpDefault = `eq` -export const metricsExplainCreateBodyQueryOneFiltersItemValueMax = 1024 - -export const metricsExplainCreateBodyQueryOneFiltersItemScopeDefault = `auto` - -export const MetricsExplainCreateBody = /* @__PURE__ */ zod.object({ - query: zod - .object({ - metricName: zod - .string() - .max(metricsExplainCreateBodyQueryOneMetricNameMax) - .describe('Exact metric name whose bucket should be taken apart.'), - metricType: zod - .union([ - zod - .enum(['gauge', 'sum', 'histogram', 'exponential_histogram', 'summary']) - .describe( - '\* `gauge` - gauge\n\* `sum` - sum\n\* `histogram` - histogram\n\* `exponential_histogram` - exponential_histogram\n\* `summary` - summary' - ), - zod.null(), - ]) - .optional() - .describe( - 'Constrain the bucket to one metric type. A name can exist as several types; without this, rows of every type sharing the name are decomposed together.\n\n\* `gauge` - gauge\n\* `sum` - sum\n\* `histogram` - histogram\n\* `exponential_histogram` - exponential_histogram\n\* `summary` - summary' - ), - aggregation: zod - .enum(['sum', 'avg', 'count', 'min', 'max', 'p95', 'rate', 'increase', 'histogram_quantile']) - .describe( - '\* `sum` - sum\n\* `avg` - avg\n\* `count` - count\n\* `min` - min\n\* `max` - max\n\* `p95` - p95\n\* `rate` - rate\n\* `increase` - increase\n\* `histogram_quantile` - histogram_quantile' - ) - .default(metricsExplainCreateBodyQueryOneAggregationDefault) - .describe( - "The aggregation whose result should be explained. 'histogram_quantile' is rejected: it reduces bucket-count arrays rather than scalar samples, so there is no per-series value to lay out.\n\n\* `sum` - sum\n\* `avg` - avg\n\* `count` - count\n\* `min` - min\n\* `max` - max\n\* `p95` - p95\n\* `rate` - rate\n\* `increase` - increase\n\* `histogram_quantile` - histogram_quantile" - ), - quantile: zod - .number() - .min(metricsExplainCreateBodyQueryOneQuantileMin) - .max(metricsExplainCreateBodyQueryOneQuantileMax) - .nullish() - .describe("Quantile in (0, 1) applied across series. Defaults to 0.95 for the 'p95' aggregation."), - filters: zod - .array( - zod.object({ - key: zod - .string() - .max(metricsExplainCreateBodyQueryOneFiltersItemKeyMax) - .describe( - "Attribute name to filter on, without any type-tag suffix (e.g. 'k8s.pod.name', 'env')." - ), - op: zod - .enum(['eq', 'neq', 'regex', 'not_regex']) - .describe('\* `eq` - eq\n\* `neq` - neq\n\* `regex` - regex\n\* `not_regex` - not_regex') - .default(metricsExplainCreateBodyQueryOneFiltersItemOpDefault) - .describe( - "Comparison operator. 'regex'\/'not_regex' use RE2 syntax. Negative operators also match rows that lack the key entirely, mirroring Prometheus negative matchers.\n\n\* `eq` - eq\n\* `neq` - neq\n\* `regex` - regex\n\* `not_regex` - not_regex" - ), - value: zod - .string() - .max(metricsExplainCreateBodyQueryOneFiltersItemValueMax) - .describe('Value to compare against. For regex operators this is the pattern.'), - scope: zod - .enum(['resource', 'attribute', 'auto']) - .describe('\* `resource` - resource\n\* `attribute` - attribute\n\* `auto` - auto') - .default(metricsExplainCreateBodyQueryOneFiltersItemScopeDefault) - .describe( - "Where the attribute lives: 'resource' = per-target resource attributes (k8s.pod.name, service.version), 'attribute' = per-datapoint attributes (http.method, path), 'auto' = resource first with per-datapoint fallback. Use 'auto' unless you know the exact scope.\n\n\* `resource` - resource\n\* `attribute` - attribute\n\* `auto` - auto" - ), - }) - ) - .optional() - .describe('Label predicates ANDed together, matching the chart the point came from.'), - bucketStart: zod.iso - .datetime({ offset: true }) - .describe("Start of the bucket to explain, as returned in a query result's 'time'. ISO 8601."), - interval: zod - .enum(['second', 'minute', 'minute_5', 'minute_15', 'hour', 'hour_6', 'day', 'week']) - .describe( - '\* `second` - second\n\* `minute` - minute\n\* `minute_5` - minute_5\n\* `minute_15` - minute_15\n\* `hour` - hour\n\* `hour_6` - hour_6\n\* `day` - day\n\* `week` - week' - ) - .describe( - 'Bucket size the point was plotted at. Must match the query that produced it, or the decomposition explains a different span.\n\n\* `second` - second\n\* `minute` - minute\n\* `minute_5` - minute_5\n\* `minute_15` - minute_15\n\* `hour` - hour\n\* `hour_6` - hour_6\n\* `day` - day\n\* `week` - week' - ), - }) - .describe('The chart point to take apart.'), -}) - export const metricsQueryCreateBodyQueryOneMetricNameMax = 255 export const metricsQueryCreateBodyQueryOneAggregationDefault = `sum` diff --git a/products/metrics/frontend/metricsSceneLogic.tsx b/products/metrics/frontend/metricsSceneLogic.tsx index bb6b562974c2..ef34f5050753 100644 --- a/products/metrics/frontend/metricsSceneLogic.tsx +++ b/products/metrics/frontend/metricsSceneLogic.tsx @@ -34,8 +34,8 @@ import { export const METRICS_SQL_EDITOR_TAB_ID = 'metrics-sql-editor' -export type MetricsSceneActiveTab = 'overview' | 'explore' | 'viewer' | 'sql' | 'fundamentals' -const VALID_ACTIVE_TABS: MetricsSceneActiveTab[] = ['overview', 'explore', 'viewer', 'sql', 'fundamentals'] +export type MetricsSceneActiveTab = 'overview' | 'explore' | 'viewer' | 'sql' +const VALID_ACTIVE_TABS: MetricsSceneActiveTab[] = ['overview', 'explore', 'viewer', 'sql'] export const DEFAULT_ACTIVE_TAB: MetricsSceneActiveTab = 'overview' // kea-router pre-parses JSON-looking params, so anything a user types into the URL can reach diff --git a/products/metrics/mcp/tools.yaml b/products/metrics/mcp/tools.yaml index 644d59e29e96..c58954d60137 100644 --- a/products/metrics/mcp/tools.yaml +++ b/products/metrics/mcp/tools.yaml @@ -47,9 +47,6 @@ tools: metrics-error-spikes-retrieve: operation: metrics_error_spikes_retrieve enabled: false - metrics-explain-create: - operation: metrics_explain_create - enabled: false metrics-has-metrics-retrieve: operation: metrics_has_metrics_retrieve enabled: false From 71fc81e5f0fead9c2b994d92f10e2b0503c8e87b Mon Sep 17 00:00:00 2001 From: "tests-posthog[bot]" <250237707+tests-posthog[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:44:24 +0000 Subject: [PATCH 08/48] chore: update OpenAPI generated types --- services/mcp/src/api/generated.ts | 195 ------------------------------ 1 file changed, 195 deletions(-) diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts index a9bf3f8fde33..c1f80de35935 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -99785,26 +99785,6 @@ export namespace Schemas { Bytes: 'bytes', } as const; - /** - * * `sum` - sum - * * `avg` - avg - * * `min` - min - * * `max` - max - * * `quantile` - quantile - * * `count_series` - count_series - */ - export type SpatialReducerEnum = typeof SpatialReducerEnum[keyof typeof SpatialReducerEnum]; - - - export const SpatialReducerEnum = { - Sum: 'sum', - Avg: 'avg', - Min: 'min', - Max: 'max', - Quantile: 'quantile', - CountSeries: 'count_series', - } as const; - /** * Raw cached payload as stored in Redis, or null on a miss. * @nullable @@ -103864,26 +103844,6 @@ export namespace Schemas { notes: string; } - /** - * * `none` - none - * * `last` - last - * * `avg_over_time` - avg_over_time - * * `sum_over_time` - sum_over_time - * * `increase` - increase - * * `pooled_samples` - pooled_samples - */ - export type TemporalReducerEnum = typeof TemporalReducerEnum[keyof typeof TemporalReducerEnum]; - - - export const TemporalReducerEnum = { - None: 'none', - Last: 'last', - AvgOverTime: 'avg_over_time', - SumOverTime: 'sum_over_time', - Increase: 'increase', - PooledSamples: 'pooled_samples', - } as const; - /** * Anthropic text, image, or tool content blocks. */ @@ -108415,101 +108375,6 @@ export namespace Schemas { results: _MetricAttributeValue[]; } - export interface _MetricSampleView { - /** Sample timestamp, ISO 8601. */ - time: string; - /** Raw stored reading, before any reduction. */ - value: number; - } - - /** - * Per-data-point attributes identifying the series. - */ - export type _MetricSeriesBreakdownLabels = {[key: string]: string}; - - /** - * Resource attributes identifying the scrape target. - */ - export type _MetricSeriesBreakdownResourceLabels = {[key: string]: string}; - - export interface _MetricSeriesBreakdown { - /** Service that reported this series. */ - service_name: string; - /** Per-data-point attributes identifying the series. */ - labels: _MetricSeriesBreakdownLabels; - /** Resource attributes identifying the scrape target. */ - resource_labels: _MetricSeriesBreakdownResourceLabels; - /** The series' raw samples in this bucket, oldest first, trimmed for display. */ - samples: _MetricSampleView[]; - /** How many samples the series actually sent, even when 'samples' was trimmed. */ - sample_count: number; - /** Whether 'samples' lists fewer samples than arrived. */ - samples_truncated: boolean; - /** - * What this series contributed after the per-series reduction. Null for percentiles, which read the pooled readings and so have no single per-series contribution. - * @nullable - */ - value: number | null; - } - - export interface _MetricBucketDecomposition { - /** Metric that was decomposed. */ - metric_name: string; - /** OTel metric type observed in the bucket. */ - metric_type: string; - /** OTel aggregation temporality observed in the bucket ('cumulative', 'delta', or empty for gauges). */ - temporality: string; - /** Aggregation that was explained. */ - aggregation: string; - /** Start of the explained bucket, ISO 8601. */ - bucket_start: string; - /** Bucket size the point was plotted at. */ - interval: string; - /** How each series' samples were collapsed to one value: 'last' for an instant gauge reading, 'avg_over_time' for an average, 'sum_over_time' for delta counters, 'increase' for cumulative counters, and 'pooled_samples' for percentiles, which skip the per-series step entirely. - * - * * `none` - none - * * `last` - last - * * `avg_over_time` - avg_over_time - * * `sum_over_time` - sum_over_time - * * `increase` - increase - * * `pooled_samples` - pooled_samples */ - temporal_reducer: TemporalReducerEnum; - /** How the per-series values were combined into the bucket's number. - * - * * `sum` - sum - * * `avg` - avg - * * `min` - min - * * `max` - max - * * `quantile` - quantile - * * `count_series` - count_series */ - spatial_reducer: SpatialReducerEnum; - /** The series behind the point, largest contributors first, trimmed for display. */ - series: _MetricSeriesBreakdown[]; - /** How many series reported in the bucket. */ - series_count: number; - /** How many raw samples the bucket held across all series. */ - sample_count: number; - /** Whether 'series' lists fewer series than reported. */ - series_truncated: boolean; - /** Whether the bucket held more raw rows than the decomposition reads. Totals are computed only over the rows that were read. */ - rows_truncated: boolean; - /** - * The bucket's value recomputed from the raw samples, independently of the query builders. Null when no series reported. - * @nullable - */ - reference_value: number | null; - /** - * The value the product would plot for this point. Null when the query returned no row. - * @nullable - */ - actual_value: number | null; - /** - * Whether the two values match. False means one of the reductions is wrong, and the series breakdown shows where they parted. Null when the raw read was truncated, so the two are not comparable. - * @nullable - */ - agrees: boolean | null; - } - export interface _MetricCatalogValuesParams { /** * Substring filter (case-insensitive) applied to metric names. @@ -108650,66 +108515,6 @@ export namespace Schemas { resource_attributes: _MetricEventSampleResourceAttributes; } - export interface _MetricExplainBody { - /** - * Exact metric name whose bucket should be taken apart. - * @maxLength 255 - */ - metricName: string; - /** Constrain the bucket to one metric type. A name can exist as several types; without this, rows of every type sharing the name are decomposed together. - * - * * `gauge` - gauge - * * `sum` - sum - * * `histogram` - histogram - * * `exponential_histogram` - exponential_histogram - * * `summary` - summary */ - metricType?: OtelMetricTypeEnum | null; - /** The aggregation whose result should be explained. 'histogram_quantile' is rejected: it reduces bucket-count arrays rather than scalar samples, so there is no per-series value to lay out. - * - * * `sum` - sum - * * `avg` - avg - * * `count` - count - * * `min` - min - * * `max` - max - * * `p95` - p95 - * * `rate` - rate - * * `increase` - increase - * * `histogram_quantile` - histogram_quantile */ - aggregation?: AggregationEnum; - /** - * Quantile in (0, 1) applied across series. Defaults to 0.95 for the 'p95' aggregation. - * @minimum 0 - * @maximum 1 - * @nullable - */ - quantile?: number | null; - /** Label predicates ANDed together, matching the chart the point came from. */ - filters?: _MetricFilter[]; - /** Start of the bucket to explain, as returned in a query result's 'time'. ISO 8601. */ - bucketStart: string; - /** Bucket size the point was plotted at. Must match the query that produced it, or the decomposition explains a different span. - * - * * `second` - second - * * `minute` - minute - * * `minute_5` - minute_5 - * * `minute_15` - minute_15 - * * `hour` - hour - * * `hour_6` - hour_6 - * * `day` - day - * * `week` - week */ - interval: MetricQueryIntervalEnum; - } - - export interface _MetricExplainRequest { - /** The chart point to take apart. */ - query: _MetricExplainBody; - } - - export interface _MetricExplainResponse { - /** The bucket taken apart. */ - decomposition: _MetricBucketDecomposition; - } - export interface _MetricName { /** Metric name as it appears in the team's data. */ name: string; From 29505b123f424c3c81b6f4ccd92ad57802ea2552 Mon Sep 17 00:00:00 2001 From: Radu Raicea Date: Thu, 1 Oct 2026 14:47:33 -0400 Subject: [PATCH 09/48] fix(aio): bound openai-compatible provider requests --- .../internal/ai-observability-judge-inputs.md | 14 ++ .../backend/llm/providers/openai.py | 25 ++- .../llm/providers/openai_compatible.py | 47 ++++- .../providers/test/test_openai_compatible.py | 194 +++++++++++++++++- 4 files changed, 263 insertions(+), 17 deletions(-) diff --git a/docs/internal/ai-observability-judge-inputs.md b/docs/internal/ai-observability-judge-inputs.md index 33bd5eafce8e..f7dfd8387adc 100644 --- a/docs/internal/ai-observability-judge-inputs.md +++ b/docs/internal/ai-observability-judge-inputs.md @@ -39,6 +39,20 @@ They sample the combined input, tool definitions, and output only when that text Implementation: [trace judge](../../posthog/temporal/ai_observability/run_trace_evaluation.py), [session judge](../../posthog/temporal/ai_observability/run_session_evaluation.py), and [generation judge](../../posthog/temporal/ai_observability/evaluation_llm_judge.py). +## OpenAI-compatible judges + +Custom OpenAI-compatible connections use the shared DNS-pinned HTTPX transport with response bounds enabled. +Every completion request has a 60-second total HTTP deadline, including connection setup, response headers, and the body. +Key validation and model listing use a 10-second total deadline. +Responses, including errors and streamed completions, are limited to 1 MiB. +The endpoint must return uncompressed responses; compressed responses are rejected before decoding. +Expired requests, rejected responses, and streams closed by the caller close their underlying connection. + +The OpenAI SDK does not retry custom-provider requests. Online evaluations use their existing Temporal retry policy for transient failures, and worker cancellation propagates to Temporal. +Models without native structured-output support retain the JSON fallback, which can make one additional bounded request. +Oversized or compressed completion responses skip the evaluation as an unreadable response without disabling the connection. +These connection and response limits also apply when using the same provider in the playground. + ## System One judges System One-compatible models are available under the existing LLM judge option. diff --git a/products/ai_observability/backend/llm/providers/openai.py b/products/ai_observability/backend/llm/providers/openai.py index dde02805dc0e..8dbe78fa0b68 100644 --- a/products/ai_observability/backend/llm/providers/openai.py +++ b/products/ai_observability/backend/llm/providers/openai.py @@ -109,6 +109,8 @@ class OpenAIAdapter: """OpenAI provider implementing the unified Client interface.""" name = "openai" + request_timeout: float = OpenAIConfig.TIMEOUT + max_retries: int = openai.DEFAULT_MAX_RETRIES def _create_client( self, @@ -125,14 +127,16 @@ def _create_client( api_key=api_key, posthog_client=posthog_client, base_url=base_url, - timeout=OpenAIConfig.TIMEOUT, + timeout=self.request_timeout, + max_retries=self.max_retries, default_headers=default_headers or None, http_client=http_client, ) return openai.OpenAI( api_key=api_key, base_url=base_url, - timeout=OpenAIConfig.TIMEOUT, + timeout=self.request_timeout, + max_retries=self.max_retries, default_headers=default_headers or None, http_client=http_client, ) @@ -145,7 +149,7 @@ def _build_http_client(self) -> httpx.Client: """ from products.ai_observability.backend.llm.providers._diagnostics import tagged_http_client - return tagged_http_client(timeout=OpenAIConfig.TIMEOUT) + return tagged_http_client(timeout=self.request_timeout) def complete( self, @@ -160,9 +164,8 @@ def complete( client = self._create_client(effective_api_key, effective_base_url, analytics) - messages: Any = self._build_messages(request) - try: + messages: Any = self._build_messages(request) if request.response_format and issubclass(request.response_format, BaseModel): try: # Try native structured output parsing first @@ -218,6 +221,8 @@ def complete( if mapped is not None: raise mapped from e raise + finally: + client.close() def _mapped_error(self, error: Exception, model: str) -> LLMError | None: """Normalize a provider exception into the shared taxonomy, or None when it isn't ours. @@ -318,12 +323,10 @@ def stream( client = self._create_client(effective_api_key, effective_base_url, analytics) - supports_reasoning = model_id in OpenAIConfig.SUPPORTED_MODELS_WITH_THINKING - reasoning_on = supports_reasoning and (request.thinking or bool(request.reasoning_level)) - - tools = self._convert_tools(request.tools) if request.tools else None - try: + supports_reasoning = model_id in OpenAIConfig.SUPPORTED_MODELS_WITH_THINKING + reasoning_on = supports_reasoning and (request.thinking or bool(request.reasoning_level)) + tools = self._convert_tools(request.tools) if request.tools else None effective_temperature = request.temperature if request.temperature is not None else OpenAIConfig.TEMPERATURE def build_common_kwargs() -> dict[str, Any]: @@ -396,6 +399,8 @@ def build_common_kwargs() -> dict[str, Any]: except Exception as e: yield stream_error_chunk(e, self._mapped_error(e, model_id), logger=logger, provider=self.name) + finally: + client.close() @staticmethod def validate_key(api_key: str, **kwargs: Any) -> tuple[str, str | None]: diff --git a/products/ai_observability/backend/llm/providers/openai_compatible.py b/products/ai_observability/backend/llm/providers/openai_compatible.py index 35be5b0de5dc..b8c424bff5a3 100644 --- a/products/ai_observability/backend/llm/providers/openai_compatible.py +++ b/products/ai_observability/backend/llm/providers/openai_compatible.py @@ -20,13 +20,21 @@ import httpx import openai +from temporalio.exceptions import CancelledError +from posthog.security.pinned_httpx import pinned_client from posthog.security.pinned_requests import SSRFBlockedError from posthog.security.url_validation import is_url_allowed, validate_url_and_pin_ips -from products.ai_observability.backend.llm.errors import ProviderConfigurationError, error_field_for_message -from products.ai_observability.backend.llm.providers._diagnostics import tagged_http_client -from products.ai_observability.backend.llm.providers.openai import OpenAIAdapter, OpenAIConfig +from products.ai_observability.backend.llm.errors import ( + LLMError, + ProviderConfigurationError, + ProviderConnectionError, + StructuredOutputParseError, + error_field_for_message, +) +from products.ai_observability.backend.llm.providers._diagnostics import _tag_response +from products.ai_observability.backend.llm.providers.openai import OpenAIAdapter from products.ai_observability.backend.llm.types import ( AnalyticsContext, CompletionRequest, @@ -44,6 +52,10 @@ DISALLOWED_BASE_URL_MESSAGE = "Base URL must be a public https:// URL (e.g. https://api.example.com/v1)" REDIRECT_MESSAGE = "The endpoint redirected to a different address, use that address as the base URL" +RESPONSE_LIMIT_MESSAGE = ( + "The endpoint returned a compressed or oversized response. " + "Configure it to return uncompressed responses no larger than 1 MiB." +) # Validating a key and listing models are cheap calls; don't inherit the long completion timeout. # The endpoint is user-configured, so a host that accepts the connection then stalls would otherwise @@ -60,6 +72,7 @@ ("Base URL must be", "base_url"), ("The endpoint did not return a model list", "base_url"), ("The endpoint redirected", "base_url"), + ("The endpoint returned a compressed or oversized response", "base_url"), ("Could not connect to the endpoint", "base_url"), ("Invalid API key", "api_key"), ) @@ -92,7 +105,14 @@ def _pinned_http_client(base_url: str, timeout: float) -> httpx.Client: verdict = validate_url_and_pin_ips(base_url) if not verdict.allowed: raise SSRFBlockedError(verdict.reason or "URL blocked by SSRF protection") - return tagged_http_client(timeout=timeout, pin=(base_url, verdict.pinned_ips), follow_redirects=False) + return pinned_client( + base_url, + verdict.pinned_ips, + timeout=timeout, + total_timeout=timeout, + follow_redirects=False, + event_hooks={"response": [_tag_response]}, + ) class OpenAICompatibleAdapter(OpenAIAdapter): @@ -107,6 +127,9 @@ class OpenAICompatibleAdapter(OpenAIAdapter): """ name = "openai_compatible" + request_timeout = 60.0 + # Temporal owns retries; SDK retries can catch thread cancellation and start another request. + max_retries = 0 def __init__(self, base_url: str = ""): self.base_url = base_url @@ -124,7 +147,17 @@ def _require_allowed_base_url(self) -> str: def _build_http_client(self) -> httpx.Client: """Pin the connection to the configured endpoint's validated address.""" - return _pinned_http_client(self._require_allowed_base_url(), OpenAIConfig.TIMEOUT) + return _pinned_http_client(self._require_allowed_base_url(), self.request_timeout) + + def _mapped_error(self, error: Exception, model: str) -> LLMError | None: + cause = error.__cause__ if isinstance(error, openai.APIConnectionError) else error + if isinstance(cause, CancelledError): + raise cause + if isinstance(cause, httpx.DecodingError): + return StructuredOutputParseError(RESPONSE_LIMIT_MESSAGE) + if isinstance(cause, httpx.TimeoutException): + return ProviderConnectionError("The endpoint exceeded the request timeout.") + return super()._mapped_error(error, model) def complete( self, @@ -183,7 +216,9 @@ def validate_key(api_key: str, **kwargs: Any) -> tuple[str, str | None]: return (LLMProviderKey.State.INVALID, REDIRECT_MESSAGE) logger.exception("%s key validation error", PROVIDER_DISPLAY_NAME) return (LLMProviderKey.State.ERROR, "Validation failed, please try again") - except openai.APIConnectionError: + except openai.APIConnectionError as error: + if isinstance(error.__cause__, httpx.DecodingError): + return (LLMProviderKey.State.INVALID, RESPONSE_LIMIT_MESSAGE) return (LLMProviderKey.State.ERROR, "Could not connect to the endpoint") except Exception: logger.exception("%s key validation error", PROVIDER_DISPLAY_NAME) diff --git a/products/ai_observability/backend/llm/providers/test/test_openai_compatible.py b/products/ai_observability/backend/llm/providers/test/test_openai_compatible.py index 6a2bb6f4088d..89a096c7f5e0 100644 --- a/products/ai_observability/backend/llm/providers/test/test_openai_compatible.py +++ b/products/ai_observability/backend/llm/providers/test/test_openai_compatible.py @@ -1,14 +1,23 @@ +import json +from collections.abc import AsyncIterator, Iterator + import pytest from unittest.mock import MagicMock, patch import httpx import openai from parameterized import parameterized +from pydantic import BaseModel +from temporalio.exceptions import CancelledError from posthog.security.pinned_requests import SSRFBlockedError from posthog.security.url_validation import PinnedUrlVerdict -from products.ai_observability.backend.llm.errors import ProviderConfigurationError +from products.ai_observability.backend.llm.errors import ( + ProviderConfigurationError, + ProviderConnectionError, + StructuredOutputParseError, +) from products.ai_observability.backend.llm.providers import openai_compatible from products.ai_observability.backend.llm.providers.openai_compatible import ( DISALLOWED_BASE_URL_MESSAGE, @@ -61,6 +70,7 @@ class TestErrorFieldForValidationMessage: ("not_found", "The endpoint did not return a model list, check the base URL", "base_url"), ("redirect", REDIRECT_MESSAGE, "base_url"), ("connection", "Could not connect to the endpoint", "base_url"), + ("response_limit", openai_compatible.RESPONSE_LIMIT_MESSAGE, "base_url"), ("bad_key", "Invalid API key", "api_key"), ("unattributed", "Rate limited, please try again later", None), ("none", None, None), @@ -216,3 +226,185 @@ def test_complete_without_api_key_raises(self, mock_openai): with pytest.raises(ValueError, match="BYOK-only"): adapter.complete(_completion_request(), None, AnalyticsContext()) mock_openai.assert_not_called() + + +class _Verdict(BaseModel): + verdict: bool + + +class _ResponseBody(httpx.SyncByteStream, httpx.AsyncByteStream): + def __init__(self, chunks: list[bytes], clock: list[float] | None = None) -> None: + self.chunks = chunks + self.clock = clock + self.closed = False + + def __iter__(self) -> Iterator[bytes]: + for chunk in self.chunks: + if self.clock is not None: + self.clock[0] += 1 + yield chunk + + async def __aiter__(self) -> AsyncIterator[bytes]: + for chunk in self: + yield chunk + + def close(self) -> None: + self.closed = True + + async def aclose(self) -> None: + self.close() + + +class TestOpenAICompatibleRequestBounds: + @pytest.mark.parametrize("operation", ["complete", "stream", "validate_key", "list_models"]) + @pytest.mark.parametrize("response_kind", ["oversized", "compressed"]) + def test_rejects_unbounded_responses(self, operation: str, response_kind: str) -> None: + body = _ResponseBody([b"x" * 8192] * 129 if response_kind == "oversized" else [b"compressed"]) + headers = {"Content-Encoding": "gzip"} if response_kind == "compressed" else {} + response = httpx.Response(200, stream=body, headers=headers) + adapter = OpenAICompatibleAdapter(base_url=ALLOWED_BASE_URL) + request = _completion_request() + request.response_format = _Verdict + + with ( + patch("httpx.HTTPTransport.handle_request", return_value=response), + patch("httpx.AsyncHTTPTransport.handle_async_request", return_value=response), + patch("openai._base_client.time.sleep"), + ): + if operation == "complete": + with pytest.raises(StructuredOutputParseError, match="compressed or oversized"): + adapter.complete(request, "test-key", AnalyticsContext(capture=False)) + elif operation == "stream": + chunks = list(adapter.stream(_completion_request(), "test-key", AnalyticsContext(capture=False))) + assert [chunk.type for chunk in chunks] == ["error"] + elif operation == "validate_key": + state, message = adapter.validate_key("test-key", base_url=ALLOWED_BASE_URL) + assert state == "invalid" + assert message is not None and "compressed or oversized" in message + else: + assert adapter.list_models("test-key", base_url=ALLOWED_BASE_URL) == [] + + assert body.closed + + @pytest.mark.parametrize("operation", ["complete", "stream"]) + @pytest.mark.parametrize("capture", [False, True]) + def test_preserves_cancellation_without_retrying(self, operation: str, capture: bool) -> None: + adapter = OpenAICompatibleAdapter(base_url=ALLOWED_BASE_URL) + request = _completion_request() + request.response_format = _Verdict + with ( + patch("httpx.HTTPTransport.handle_request", side_effect=CancelledError) as sync_send, + patch("httpx.AsyncHTTPTransport.handle_async_request", side_effect=CancelledError) as async_send, + patch("openai._base_client.time.sleep"), + patch("posthoganalytics.default_client", MagicMock()), + pytest.raises(CancelledError), + ): + if operation == "complete": + adapter.complete(request, "test-key", AnalyticsContext(capture=capture)) + else: + list(adapter.stream(request, "test-key", AnalyticsContext(capture=capture))) + + assert sync_send.call_count + async_send.call_count == 1 + + @pytest.mark.parametrize("operation", ["complete", "stream", "validate_key", "list_models"]) + def test_total_deadline_stops_dripping_response(self, operation: str) -> None: + payload = json.dumps( + { + "id": "fixture", + "object": "chat.completion", + "created": 0, + "model": "some-model", + "choices": [{"index": 0, "finish_reason": "stop", "message": {"role": "assistant", "content": "ok"}}], + } + ).encode() + clock = [0.0] + body = _ResponseBody([b" "] * 4 + [payload], clock) + response = httpx.Response(200, stream=body, headers={"Content-Type": "application/json"}) + adapter = OpenAICompatibleAdapter(base_url=ALLOWED_BASE_URL) + + with ( + patch.object(adapter, "request_timeout", 2.0), + patch.object(openai_compatible, "VALIDATION_TIMEOUT", 2.0), + patch("asyncio.BaseEventLoop.time", side_effect=lambda: clock[0]), + patch("httpx.HTTPTransport.handle_request", return_value=response) as sync_send, + patch("httpx.AsyncHTTPTransport.handle_async_request", return_value=response) as async_send, + patch("openai._base_client.time.sleep"), + ): + if operation == "complete": + with pytest.raises(ProviderConnectionError): + adapter.complete(_completion_request(), "test-key", AnalyticsContext(capture=False)) + elif operation == "stream": + chunks = list(adapter.stream(_completion_request(), "test-key", AnalyticsContext(capture=False))) + assert [chunk.type for chunk in chunks] == ["error"] + elif operation == "validate_key": + state, _ = adapter.validate_key("test-key", base_url=ALLOWED_BASE_URL) + assert state == "error" + else: + assert adapter.list_models("test-key", base_url=ALLOWED_BASE_URL) == [] + + assert sync_send.call_count + async_send.call_count == 1 + assert body.closed + + def test_structured_output_falls_back_without_sdk_retries(self) -> None: + fallback_body = _ResponseBody( + [ + json.dumps( + { + "id": "fixture", + "object": "chat.completion", + "created": 0, + "model": "some-model", + "choices": [ + { + "index": 0, + "finish_reason": "stop", + "message": {"role": "assistant", "content": '{"verdict": true}'}, + } + ], + } + ).encode() + ] + ) + responses = [ + httpx.Response( + 400, + stream=_ResponseBody([b'{"error":{"message":"response_format json_schema is not supported"}}']), + headers={"Content-Type": "application/json"}, + ), + httpx.Response(200, stream=fallback_body, headers={"Content-Type": "application/json"}), + ] + request = _completion_request() + request.response_format = _Verdict + + with patch("httpx.AsyncHTTPTransport.handle_async_request", side_effect=responses) as send: + result = OpenAICompatibleAdapter(base_url=ALLOWED_BASE_URL).complete( + request, "test-key", AnalyticsContext(capture=False) + ) + + assert result.parsed == _Verdict(verdict=True) + assert send.call_count == 2 + assert fallback_body.closed + + def test_closing_stream_closes_the_connection(self) -> None: + payload = json.dumps( + { + "id": "fixture", + "object": "chat.completion.chunk", + "created": 0, + "model": "some-model", + "choices": [{"index": 0, "delta": {"content": "hello"}, "finish_reason": None}], + } + ).encode() + body = _ResponseBody([b"data: " + payload + b"\n\n", b"data: [DONE]\n\n"]) + response = httpx.Response(200, stream=body, headers={"Content-Type": "text/event-stream"}) + adapter = OpenAICompatibleAdapter(base_url=ALLOWED_BASE_URL) + + with ( + patch("httpx.HTTPTransport.handle_request", return_value=response), + patch("httpx.AsyncHTTPTransport.handle_async_request", return_value=response), + ): + stream = adapter.stream(_completion_request(), "test-key", AnalyticsContext(capture=False)) + assert next(stream).data == {"text": "hello"} + stream.close() + + assert body.closed From bcfd8125418d3a19a46eb8effbbfa883d7ac9ef9 Mon Sep 17 00:00:00 2001 From: Radu Raicea Date: Thu, 1 Oct 2026 16:13:54 -0400 Subject: [PATCH 10/48] fix(aio): close bounded streams outside active event loops --- .../internal/ai-observability-judge-inputs.md | 1 + posthog/security/bounded_httpx.py | 19 +++++++++++++++---- .../providers/test/test_openai_compatible.py | 18 +++++++++++++++--- 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/docs/internal/ai-observability-judge-inputs.md b/docs/internal/ai-observability-judge-inputs.md index f7dfd8387adc..93b8d75463c1 100644 --- a/docs/internal/ai-observability-judge-inputs.md +++ b/docs/internal/ai-observability-judge-inputs.md @@ -47,6 +47,7 @@ Key validation and model listing use a 10-second total deadline. Responses, including errors and streamed completions, are limited to 1 MiB. The endpoint must return uncompressed responses; compressed responses are rejected before decoding. Expired requests, rejected responses, and streams closed by the caller close their underlying connection. +Stream cleanup also closes the connection when a playground disconnect finalizes a generator on an active event-loop thread. The OpenAI SDK does not retry custom-provider requests. Online evaluations use their existing Temporal retry policy for transient failures, and worker cancellation propagates to Temporal. Models without native structured-output support retain the JSON fallback, which can make one additional bounded request. diff --git a/posthog/security/bounded_httpx.py b/posthog/security/bounded_httpx.py index 804235bb4dd9..fbb1b31e970f 100644 --- a/posthog/security/bounded_httpx.py +++ b/posthog/security/bounded_httpx.py @@ -1,5 +1,6 @@ import asyncio from collections.abc import Awaitable, Callable, Iterator +from concurrent.futures import ThreadPoolExecutor from typing import Any from django.utils.asyncio import async_unsafe @@ -79,16 +80,26 @@ async def _close(self) -> None: finally: await self._transport.aclose() - def close(self) -> None: - if self._closed: - return - self._closed = True + def _close_in_runner(self) -> None: try: self._runner.run(self._close()) finally: self._runner.close() self._owner.streams.discard(self) + def close(self) -> None: + if self._closed: + return + self._closed = True + try: + asyncio.get_running_loop() + except RuntimeError: + self._close_in_runner() + else: + # ASGI disconnects can finalize generators on an active event-loop thread, which cannot run another loop. + with ThreadPoolExecutor(max_workers=1) as executor: + executor.submit(self._close_in_runner).result() + class BoundedHTTPTransport(httpx.BaseTransport): """Sync HTTPX interface with cancellable I/O and a bounded response body. diff --git a/products/ai_observability/backend/llm/providers/test/test_openai_compatible.py b/products/ai_observability/backend/llm/providers/test/test_openai_compatible.py index 89a096c7f5e0..fb7403c3ea74 100644 --- a/products/ai_observability/backend/llm/providers/test/test_openai_compatible.py +++ b/products/ai_observability/backend/llm/providers/test/test_openai_compatible.py @@ -1,4 +1,5 @@ import json +import asyncio from collections.abc import AsyncIterator, Iterator import pytest @@ -29,6 +30,8 @@ ) from products.ai_observability.backend.llm.types import AnalyticsContext, CompletionRequest +from ee.hogai.utils.asgi import SyncIterableToAsync + # A public IP literal keeps the DNS-resolution check offline in tests. ALLOWED_BASE_URL = "https://8.8.8.8/v1" @@ -385,7 +388,8 @@ def test_structured_output_falls_back_without_sdk_retries(self) -> None: assert send.call_count == 2 assert fallback_body.closed - def test_closing_stream_closes_the_connection(self) -> None: + @pytest.mark.parametrize("close_in_event_loop", [False, True]) + def test_closing_stream_closes_the_connection(self, close_in_event_loop: bool) -> None: payload = json.dumps( { "id": "fixture", @@ -404,7 +408,15 @@ def test_closing_stream_closes_the_connection(self) -> None: patch("httpx.AsyncHTTPTransport.handle_async_request", return_value=response), ): stream = adapter.stream(_completion_request(), "test-key", AnalyticsContext(capture=False)) - assert next(stream).data == {"text": "hello"} - stream.close() + if close_in_event_loop: + + async def consume_then_close() -> None: + assert (await anext(SyncIterableToAsync(stream))).data == {"text": "hello"} + stream.close() + + asyncio.run(consume_then_close()) + else: + assert next(stream).data == {"text": "hello"} + stream.close() assert body.closed From 3e331e33697a554823f012896e76b40dca5bb55e Mon Sep 17 00:00:00 2001 From: Julian Bez Date: Thu, 1 Oct 2026 13:24:08 +0200 Subject: [PATCH 11/48] refactor(oauth): mint direct access tokens through one core helper Direct-mint sites each built an OAuthAccessToken row by hand: generate the token value, compute expiry from now, set scope and team pinning. Copies of security-sensitive code drift, and new callers (WebMCP is next) would add one more. `mint_oauth_access_token` in posthog/models/oauth.py now owns that, next to the model and the other token lookups, so products can import it without reaching into posthog.temporal. - Move the sandbox/wizard run mint in posthog/temporal/oauth.py and both streamlit_apps mints (iframe and bridge) onto the helper. - No behavior change: same token format, scope strings, lifetimes and scoped_teams; sandbox_task_id stays null where it was not set. - Leave the authorization-code/refresh flows (oauth views, agentic and Stripe provisioning, Stripe integration, generate_stripe_app_tokens) alone. They pair the access token with a refresh token, rotate rows, or take values from oauthlib, so they stay explicit. --- posthog/models/oauth.py | 34 ++++++++++++++++++- posthog/temporal/oauth.py | 23 +++++-------- .../streamlit_apps/backend/logic/oauth.py | 25 +++++--------- 3 files changed, 50 insertions(+), 32 deletions(-) diff --git a/posthog/models/oauth.py b/posthog/models/oauth.py index d698c01af7cf..696aad334c61 100644 --- a/posthog/models/oauth.py +++ b/posthog/models/oauth.py @@ -1,5 +1,6 @@ import enum import uuid +from datetime import timedelta from typing import TYPE_CHECKING, cast from urllib.parse import urlparse @@ -27,7 +28,13 @@ from posthog.models.activity_logging.model_activity import ModelActivityMixin from posthog.models.user import User -from posthog.models.utils import UUIDT, generate_random_token, hash_key_value, mask_key_value +from posthog.models.utils import ( + UUIDT, + generate_random_oauth_access_token, + generate_random_token, + hash_key_value, + mask_key_value, +) if TYPE_CHECKING: from posthog.models import Organization, User @@ -647,6 +654,31 @@ class Meta(AbstractGrant.Meta): ) +def mint_oauth_access_token( + *, + application: OAuthApplication, + user: "User | None", + scope: str, + lifetime: timedelta, + scoped_teams: list[int], + sandbox_task_id: uuid.UUID | None = None, +) -> OAuthAccessToken: + """Mint a fresh access token directly, outside the OAuth grant flow. + + The caller owns the scope and lifetime decision. Callers that also issue a refresh token + or rotate an existing one create their rows by hand, inside their own transaction. + """ + return OAuthAccessToken.objects.create( + application=application, + user=user, + token=generate_random_oauth_access_token(None), + expires=timezone.now() + lifetime, + scope=scope, + scoped_teams=scoped_teams, + sandbox_task_id=sandbox_task_id, + ) + + def find_oauth_access_token(token: str) -> OAuthAccessToken | None: """Find an OAuth access token by its value using the token_checksum index.""" from hashlib import sha256 diff --git a/posthog/temporal/oauth.py b/posthog/temporal/oauth.py index 94c858fc1dca..2a9f6721ec81 100644 --- a/posthog/temporal/oauth.py +++ b/posthog/temporal/oauth.py @@ -4,14 +4,13 @@ from uuid import UUID from django.conf import settings -from django.utils import timezone import structlog from posthog.llm.wizard_blocklist import WIZARD_BLOCKED_DETAIL, wizard_identity_blocked -from posthog.models import OAuthAccessToken, OAuthApplication +from posthog.models import OAuthApplication +from posthog.models.oauth import mint_oauth_access_token from posthog.models.team.team import Team -from posthog.models.utils import generate_random_oauth_access_token from posthog.scopes import ( API_SCOPE_OBJECTS, INTERNAL_API_SCOPE_OBJECTS, @@ -577,22 +576,18 @@ def get_sandbox_oauth_app(application: SandboxOAuthApplication = "array") -> OAu return get_array_app() -def _mint_oauth_access_token( +def _mint_run_access_token( user, team_id: int, *, app: OAuthApplication, scopes: list[str], sandbox_task_id: UUID | None = None ) -> str: - token_value = generate_random_oauth_access_token(None) - - OAuthAccessToken.objects.create( - user=user, + access_token = mint_oauth_access_token( application=app, - token=token_value, - expires=timezone.now() + timedelta(seconds=TOKEN_EXPIRATION_SECONDS), + user=user, scope=" ".join(dict.fromkeys(scopes)), + lifetime=timedelta(seconds=TOKEN_EXPIRATION_SECONDS), scoped_teams=[team_id], sandbox_task_id=sandbox_task_id, ) - - return token_value + return access_token.token def create_oauth_access_token_for_user( @@ -625,7 +620,7 @@ def create_oauth_access_token_for_user( if include_slack_run_scope: resolved.append(SLACK_RUN_SCOPE) app = get_sandbox_oauth_app(application) - return _mint_oauth_access_token(user, team_id, app=app, scopes=list(resolved), sandbox_task_id=sandbox_task_id) + return _mint_run_access_token(user, team_id, app=app, scopes=list(resolved), sandbox_task_id=sandbox_task_id) def get_wizard_app() -> OAuthApplication: @@ -686,4 +681,4 @@ def create_wizard_oauth_access_token_for_user(user, team_id: int) -> str: if ceiling is None or len(ceiling) == 0: raise RuntimeError("Wizard app has no scope ceiling. Must be configured in the database.") - return _mint_oauth_access_token(user, team_id, app=app, scopes=sorted(ceiling)) + return _mint_run_access_token(user, team_id, app=app, scopes=sorted(ceiling)) diff --git a/products/streamlit_apps/backend/logic/oauth.py b/products/streamlit_apps/backend/logic/oauth.py index e18ab691ba79..9ffc6154df05 100644 --- a/products/streamlit_apps/backend/logic/oauth.py +++ b/products/streamlit_apps/backend/logic/oauth.py @@ -7,9 +7,8 @@ import structlog -from posthog.models.oauth import OAuthAccessToken, OAuthApplication +from posthog.models.oauth import OAuthAccessToken, OAuthApplication, mint_oauth_access_token from posthog.models.user import User -from posthog.models.utils import generate_random_oauth_access_token logger = structlog.get_logger(__name__) @@ -58,15 +57,11 @@ def create_streamlit_access_token(user: User, team_id: int) -> OAuthAccessToken: Returns the ORM object so callers can read the real `expires` timestamp instead of reporting the minting TTL. """ - oauth_app = get_streamlit_oauth_app() - token_value = generate_random_oauth_access_token(None) - - return OAuthAccessToken.objects.create( - application=oauth_app, - token=token_value, + return mint_oauth_access_token( + application=get_streamlit_oauth_app(), user=user, - expires=timezone.now() + timedelta(seconds=ACCESS_TOKEN_EXPIRY_SECONDS), scope=IFRAME_TOKEN_SCOPE, + lifetime=timedelta(seconds=ACCESS_TOKEN_EXPIRY_SECONDS), scoped_teams=[team_id], ) @@ -100,15 +95,11 @@ def create_sandbox_bridge_token(user: User | None, team_id: int) -> str: `user`, which the bridge's org-membership re-check relies on to revoke access when the minting user leaves the org. A PSAK is user-less and couldn't gate that. """ - oauth_app = get_streamlit_oauth_app() - token_value = generate_random_oauth_access_token(None) - - OAuthAccessToken.objects.create( - application=oauth_app, - token=token_value, + access_token = mint_oauth_access_token( + application=get_streamlit_oauth_app(), user=user, - expires=timezone.now() + timedelta(seconds=BRIDGE_TOKEN_EXPIRY_SECONDS), scope=BRIDGE_TOKEN_SCOPE, + lifetime=timedelta(seconds=BRIDGE_TOKEN_EXPIRY_SECONDS), scoped_teams=[team_id], ) - return token_value + return access_token.token From a46afafe42ec99cf76fdf2dc5100d4d7b7134367 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:35:43 +0000 Subject: [PATCH 12/48] chore(visual): update storybook baselines 2 updated Run: 92718009-080f-48a1-bb19-8fc805c612cd Co-authored-by: mariusandra <53387+mariusandra@users.noreply.github.com> --- frontend/snapshots.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/snapshots.yml b/frontend/snapshots.yml index 159a0cb06e89..7e872d0a305a 100644 --- a/frontend/snapshots.yml +++ b/frontend/snapshots.yml @@ -8566,6 +8566,10 @@ snapshots: hash: v1.k794b7964.22840c30d80b9e1a3bd41d5a31af2ab81a54d9f90fb82d540fb9494b44cda050.oC_UUgUXZVqKMZ6LuInaQADAzqeWAvw2dZ8T5eENhIQ scenes-app-data-warehouse-settings-schemas--multi-schema--light: hash: v1.k794b7964.e5d678194a8e191fdf4f8a92cd7328f1e3cb162a81d3c4d05ef608f3c61537da.ZshlecoDn8CpsaVHaONi7UiXq8r-Xi4zJSWKeg7nAP0 + scenes-app-data-warehouse-sql-editor--bi-mode-worksheet--dark: + hash: v1.k794b7964.ed1827730f8473edfc25b095242a52b69c0f567ae6e7473e8830792f56e4f00b.uBeh0xaWPYyrbED7Bccx7pkJ0uDHxeX92qCsI6VixuA + scenes-app-data-warehouse-sql-editor--bi-mode-worksheet--light: + hash: v1.k794b7964.c0d402abf71c4b0869bf2e88773812a4b36945a63bae947d512fd48d7b6422fa.ANiEHn-ocUTcXvHPyYKVv9Mayy2gO187S1lLhRt3cuY scenes-app-data-warehouse-sql-editor--lazy-schema--dark: hash: v1.k794b7964.d88e418e3b5cf90da6e2d95a6b03a83eb4b2f30ebd62d2ccd787627f05c6925f.lXqUNz05NbvRissLDZgit8MPEI_j8olx5ewv4hxIbLg scenes-app-data-warehouse-sql-editor--lazy-schema--light: From cfedc34b3b86bff8a1a34782b0a8409916933d72 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Fri, 2 Oct 2026 15:17:22 +0200 Subject: [PATCH 13/48] feat(canvas): report the clicked line with comment-activate The canvas runtime now sends the client rect of the clicked highlight line with comment-activate. The field is optional, so builds made before this change stay valid. The web host moves the rect into page coordinates and passes it to onCommentActivate. The web sandbox document is regenerated from the runtime source. Co-Authored-By: Claude Opus 5.5 Generated-By: PostHog Desktop Task-Id: b999fc3e-c145-4e0e-9851-6ef273201d04 --- .../backend/sandbox/sandbox_document.html | 2 +- products/canvas/frontend/host/BuiltCanvas.tsx | 7 +++++- products/canvas/frontend/host/DraftCanvas.tsx | 7 +++++- .../host/canvasHostMessageRouter.test.ts | 23 +++++++++++++++-- .../frontend/host/canvasHostMessageRouter.ts | 5 ++-- .../canvas/frontend/host/canvasProtocol.ts | 22 ++++++++++------ .../comments/canvasCommentThreads.ts | 25 ++++++++++--------- .../core/src/canvas/freeformSchemas.ts | 15 ++++++----- .../canvas/freeform/sandboxRuntime.ts | 2 +- 9 files changed, 75 insertions(+), 33 deletions(-) diff --git a/products/canvas/backend/sandbox/sandbox_document.html b/products/canvas/backend/sandbox/sandbox_document.html index 1cb4b268e94f..8e5a888c136a 100644 --- a/products/canvas/backend/sandbox/sandbox_document.html +++ b/products/canvas/backend/sandbox/sandbox_document.html @@ -586,7 +586,7 @@ if (event.clientX >= rect.left && event.clientX <= rect.right && event.clientY >= rect.top && event.clientY <= rect.bottom) { event.preventDefault(); event.stopPropagation(); - post({ type: "comment-activate", id: item.id }); + post({ type: "comment-activate", id: item.id, rect: { top: rect.top, right: rect.right, bottom: rect.bottom, left: rect.left } }); return; } } diff --git a/products/canvas/frontend/host/BuiltCanvas.tsx b/products/canvas/frontend/host/BuiltCanvas.tsx index 1703ff09445c..0772eececc31 100644 --- a/products/canvas/frontend/host/BuiltCanvas.tsx +++ b/products/canvas/frontend/host/BuiltCanvas.tsx @@ -1,7 +1,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef } from 'react' import type { CanvasCapabilitiesApi } from '../generated/api.schemas' -import { translateCanvasTextSelection } from '../sidePanel/comments/canvasCommentThreads' +import { translateCanvasRect, translateCanvasTextSelection } from '../sidePanel/comments/canvasCommentThreads' import { assertCanvasCapability } from './canvasCapabilities' import { CanvasHostCallbacks, createCanvasHostMessageRouter } from './canvasHostMessageRouter' import { @@ -75,6 +75,11 @@ export function BuiltCanvas({ latest.current.callbacks.onTextSelection?.( translateCanvasTextSelection(selection, iframe?.getBoundingClientRect() ?? null) ), + onCommentActivate: (id, rect) => + latest.current.callbacks.onCommentActivate?.( + id, + rect ? translateCanvasRect(rect, iframe?.getBoundingClientRect() ?? null) : null + ), }), hasUserActivation: () => latest.current.hasUserActivation(), openExternal: (url) => latest.current.onOpenExternal(url), diff --git a/products/canvas/frontend/host/DraftCanvas.tsx b/products/canvas/frontend/host/DraftCanvas.tsx index c21efaad6e7d..0b5c7d5cf812 100644 --- a/products/canvas/frontend/host/DraftCanvas.tsx +++ b/products/canvas/frontend/host/DraftCanvas.tsx @@ -1,7 +1,7 @@ import { useEffect, useLayoutEffect, useRef } from 'react' import type { CanvasCapabilitiesApi } from '../generated/api.schemas' -import { translateCanvasTextSelection } from '../sidePanel/comments/canvasCommentThreads' +import { translateCanvasRect, translateCanvasTextSelection } from '../sidePanel/comments/canvasCommentThreads' import { assertCanvasCapability } from './canvasCapabilities' import { CanvasDocumentBridge } from './canvasDocumentBridge' import { CanvasHostCallbacks, createCanvasHostMessageRouter } from './canvasHostMessageRouter' @@ -107,6 +107,11 @@ export function DraftCanvas({ latest.current.callbacks.onTextSelection?.( translateCanvasTextSelection(selection, iframeRef.current?.getBoundingClientRect() ?? null) ), + onCommentActivate: (id, rect) => + latest.current.callbacks.onCommentActivate?.( + id, + rect ? translateCanvasRect(rect, iframeRef.current?.getBoundingClientRect() ?? null) : null + ), }), hasUserActivation: () => latest.current.hasUserActivation(), openExternal: (url) => latest.current.onOpenExternal(url), diff --git a/products/canvas/frontend/host/canvasHostMessageRouter.test.ts b/products/canvas/frontend/host/canvasHostMessageRouter.test.ts index 9546f87c8cd6..f1deef3738e9 100644 --- a/products/canvas/frontend/host/canvasHostMessageRouter.test.ts +++ b/products/canvas/frontend/host/canvasHostMessageRouter.test.ts @@ -11,6 +11,7 @@ function setup(options: { activated?: boolean; onDataRequest?: () => Promise Promise ({ ok: true }))) const onNavigate = jest.fn() + const onCommentActivate = jest.fn() const openExternal = jest.fn() const onExternalOpenBlocked = jest.fn() const clock = { now: 10_000 } const route = createCanvasHostMessageRouter({ post: (message) => posted.push(message), - callbacks: () => ({ onDataRequest, onNavigate }), + callbacks: () => ({ onDataRequest, onNavigate, onCommentActivate }), hasUserActivation: () => options.activated ?? false, openExternal, onExternalOpenBlocked, now: () => clock.now, }) - return { route, posted, onDataRequest, onNavigate, openExternal, onExternalOpenBlocked, clock } + return { route, posted, onDataRequest, onNavigate, onCommentActivate, openExternal, onExternalOpenBlocked, clock } } const dataRequest = (method: string, payload: unknown = {}): CanvasToHostMessage => @@ -138,6 +140,23 @@ describe('createCanvasHostMessageRouter', () => { expect(onNavigate).toHaveBeenCalledTimes(forwarded ? 1 : 0) }) + test.each([ + ['with the clicked line', { top: 10, right: 90, bottom: 30, left: 20 }], + ['from a build that reports no line', undefined], + ])('comment-activate %s reaches the host', async (_, rect) => { + const { route, onCommentActivate } = setup() + const message = canvasToHostMessageSchema.parse({ + channel: 'posthog-canvas', + type: 'comment-activate', + id: 'thread-1', + ...(rect ? { rect } : {}), + }) + + await route(message) + + expect(onCommentActivate).toHaveBeenCalledWith('thread-1', rect ?? null) + }) + test('open-external needs a gesture and is throttled', async () => { const blocked = setup({ activated: false }) await blocked.route({ channel: 'posthog-canvas', type: 'open-external', url: 'https://posthog.com/docs' }) diff --git a/products/canvas/frontend/host/canvasHostMessageRouter.ts b/products/canvas/frontend/host/canvasHostMessageRouter.ts index 7efc962d66ed..d3631f492b7a 100644 --- a/products/canvas/frontend/host/canvasHostMessageRouter.ts +++ b/products/canvas/frontend/host/canvasHostMessageRouter.ts @@ -1,6 +1,7 @@ import { CANVAS_CHANNEL, CanvasNavIntent, + CanvasRect, CanvasTextSelection, CanvasToHostMessage, HostToCanvasMessage, @@ -70,7 +71,7 @@ export interface CanvasHostCallbacks { onTextSelection?: (selection: CanvasTextSelection) => void onTextSelectionCleared?: () => void /** The viewer clicked a highlighted comment anchor. */ - onCommentActivate?: (id: string) => void + onCommentActivate?: (id: string, rect: CanvasRect | null) => void } export type ExternalOpenBlockReason = 'unsafe-url' | 'no-interaction' | 'throttled' @@ -215,7 +216,7 @@ export function createCanvasHostMessageRouter( options.callbacks().onTextSelectionCleared?.() break case 'comment-activate': - options.callbacks().onCommentActivate?.(message.id) + options.callbacks().onCommentActivate?.(message.id, message.rect ?? null) break } } diff --git a/products/canvas/frontend/host/canvasProtocol.ts b/products/canvas/frontend/host/canvasProtocol.ts index abcd99d2e308..b6925dd34977 100644 --- a/products/canvas/frontend/host/canvasProtocol.ts +++ b/products/canvas/frontend/host/canvasProtocol.ts @@ -94,6 +94,14 @@ export const CANVAS_DATA_METHODS = [ ] as const export type CanvasDataMethod = (typeof CANVAS_DATA_METHODS)[number] +const canvasRectSchema = z.object({ + top: z.number().finite(), + right: z.number().finite(), + bottom: z.number().finite(), + left: z.number().finite(), +}) +export type CanvasRect = z.infer + const canvasTextSelectionSchema = z .object({ quote: z.string(), @@ -101,12 +109,7 @@ const canvasTextSelectionSchema = z suffix: z.string(), start: z.number().int().min(0), end: z.number().int().min(0), - rect: z.object({ - top: z.number().finite(), - right: z.number().finite(), - bottom: z.number().finite(), - left: z.number().finite(), - }), + rect: canvasRectSchema, }) .refine(({ start, end }) => end > start) /** A selection the canvas reported, in the frame's own coordinates. */ @@ -145,7 +148,12 @@ export const canvasToHostMessageSchema = z.discriminatedUnion('type', [ }), z.object({ channel, type: z.literal('text-selection'), selection: canvasTextSelectionSchema }), z.object({ channel, type: z.literal('text-selection-cleared') }), - z.object({ channel, type: z.literal('comment-activate'), id: z.string().min(1).max(128) }), + z.object({ + channel, + type: z.literal('comment-activate'), + id: z.string().min(1).max(128), + rect: canvasRectSchema.optional(), + }), z.object({ channel, type: z.literal('keydown'), diff --git a/products/canvas/frontend/sidePanel/comments/canvasCommentThreads.ts b/products/canvas/frontend/sidePanel/comments/canvasCommentThreads.ts index d3cd101316b4..27b612507df3 100644 --- a/products/canvas/frontend/sidePanel/comments/canvasCommentThreads.ts +++ b/products/canvas/frontend/sidePanel/comments/canvasCommentThreads.ts @@ -1,6 +1,6 @@ import type { CommentType } from '~/types' -import type { CanvasCommentHighlight, CanvasTextSelection } from '../../host/canvasProtocol' +import type { CanvasCommentHighlight, CanvasRect, CanvasTextSelection } from '../../host/canvasProtocol' // Canvas comments are ordinary PostHog comments with scope "canvas" and the canvas id as // item_id. The item_context shape is shared with PostHog Desktop, which writes the same @@ -132,22 +132,23 @@ export function canvasCommentHighlights( return highlights } +export function translateCanvasRect(rect: CanvasRect, frame: Pick | null): CanvasRect { + const left = frame?.left ?? 0 + const top = frame?.top ?? 0 + return { + top: rect.top + top, + right: rect.right + left, + bottom: rect.bottom + top, + left: rect.left + left, + } +} + /** Moves a selection rect from the frame's coordinates into the page's, so the host can anchor UI to it. */ export function translateCanvasTextSelection( selection: CanvasTextSelection, frame: Pick | null ): CanvasTextSelection { - const left = frame?.left ?? 0 - const top = frame?.top ?? 0 - return { - ...selection, - rect: { - top: selection.rect.top + top, - right: selection.rect.right + left, - bottom: selection.rect.bottom + top, - left: selection.rect.left + left, - }, - } + return { ...selection, rect: translateCanvasRect(selection.rect, frame) } } /** The anchor stored on a comment made from a selection. */ diff --git a/products/desktop/packages/core/src/canvas/freeformSchemas.ts b/products/desktop/packages/core/src/canvas/freeformSchemas.ts index 09d9417b8f29..51c6896450b5 100644 --- a/products/desktop/packages/core/src/canvas/freeformSchemas.ts +++ b/products/desktop/packages/core/src/canvas/freeformSchemas.ts @@ -187,13 +187,15 @@ export type CanvasAnalyticsConfig = z.infer; export const canvasThemeSchema = z.enum(["light", "dark"]); export type CanvasTheme = z.infer; +const canvasRectSchema = z.object({ + top: z.number().finite(), + right: z.number().finite(), + bottom: z.number().finite(), + left: z.number().finite(), +}); + const canvasTextSelectionDataSchema = textCommentAnchorDataSchema.extend({ - rect: z.object({ - top: z.number().finite(), - right: z.number().finite(), - bottom: z.number().finite(), - left: z.number().finite(), - }), + rect: canvasRectSchema, }); export const canvasTextSelectionSchema = canvasTextSelectionDataSchema.refine( ({ start, end }) => end > start, @@ -392,6 +394,7 @@ export const canvasToHostMessageSchema = z.discriminatedUnion("type", [ channel: z.literal(CANVAS_CHANNEL), type: z.literal("comment-activate"), id: z.string().min(1).max(128), + rect: canvasRectSchema.optional(), }), z.object({ channel: z.literal(CANVAS_CHANNEL), diff --git a/products/desktop/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts b/products/desktop/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts index 7768dcf12461..42b9011c7d9e 100644 --- a/products/desktop/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts +++ b/products/desktop/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts @@ -527,7 +527,7 @@ export function buildSandboxDocument( if (event.clientX >= rect.left && event.clientX <= rect.right && event.clientY >= rect.top && event.clientY <= rect.bottom) { event.preventDefault(); event.stopPropagation(); - post({ type: "comment-activate", id: item.id }); + post({ type: "comment-activate", id: item.id, rect: { top: rect.top, right: rect.right, bottom: rect.bottom, left: rect.left } }); return; } } From f15cea90b0d819d5f403339b7bf1d26a04ec3089 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Fri, 2 Oct 2026 15:19:43 +0200 Subject: [PATCH 14/48] feat(canvas): show canvas comment threads inline A click on a highlight opens the thread in a popover at that line. A new comments menu in the canvas header lists every thread. The Comments side panel tab is removed. Co-Authored-By: Claude Opus 5.5 Generated-By: PostHog Desktop Task-Id: b999fc3e-c145-4e0e-9851-6ef273201d04 --- .../navigation-3000/sidepanel/SidePanel.tsx | 7 +- .../sidepanel/sidePanelLogic.tsx | 7 +- frontend/src/types.ts | 1 - products/canvas/frontend/canvasAnalytics.ts | 2 + .../canvas/frontend/scene/CanvasHostFrame.tsx | 5 +- .../frontend/scene/CanvasScene.stories.tsx | 78 +++++- .../canvas/frontend/scene/CanvasScene.tsx | 2 + .../frontend/scene/CanvasSceneHeader.tsx | 2 + .../frontend/sidePanel/CanvasSidePanel.tsx | 19 +- .../sidePanel/CanvasSidePanelTabBody.tsx | 3 - .../frontend/sidePanel/canvasPanelTabs.ts | 3 +- .../comments/CanvasCommentThreadCard.tsx | 88 +++---- .../comments/CanvasCommentThreadPopover.tsx | 92 +++++++ .../sidePanel/comments/CanvasCommentsMenu.tsx | 225 ++++++++++++++++++ .../sidePanel/comments/CanvasCommentsTab.tsx | 131 ---------- .../sidePanel/comments/canvasCommentsLogic.ts | 47 ++-- 16 files changed, 488 insertions(+), 224 deletions(-) create mode 100644 products/canvas/frontend/sidePanel/comments/CanvasCommentThreadPopover.tsx create mode 100644 products/canvas/frontend/sidePanel/comments/CanvasCommentsMenu.tsx delete mode 100644 products/canvas/frontend/sidePanel/comments/CanvasCommentsTab.tsx diff --git a/frontend/src/layout/navigation-3000/sidepanel/SidePanel.tsx b/frontend/src/layout/navigation-3000/sidepanel/SidePanel.tsx index 2a2e82f38485..f469b46173c8 100644 --- a/frontend/src/layout/navigation-3000/sidepanel/SidePanel.tsx +++ b/frontend/src/layout/navigation-3000/sidepanel/SidePanel.tsx @@ -3,7 +3,7 @@ import './SidePanel.scss' import { useActions, useValues } from 'kea' import { Suspense, useEffect, useRef } from 'react' -import { IconApps, IconChat, IconComment, IconLock, IconLogomark, IconNotebook, IconPulse } from '@posthog/icons' +import { IconApps, IconChat, IconLock, IconLogomark, IconNotebook, IconPulse } from '@posthog/icons' import { Resizer } from 'lib/components/Resizer/Resizer' import { ResizerLogicProps, resizerLogic } from 'lib/components/Resizer/resizerLogic' @@ -113,11 +113,6 @@ export const SIDE_PANEL_TABS: Record setTextSelection(null), - onCommentActivate: commentsEnabled ? activateThread : undefined, + onCommentActivate: commentsEnabled + ? (id: string, rect: CanvasRect | null) => activateThread(id, rect, 'highlight') + : undefined, commentHighlights: highlights, clearTextSelectionKey, hasUserActivation: canvasHasUserActivation, diff --git a/products/canvas/frontend/scene/CanvasScene.stories.tsx b/products/canvas/frontend/scene/CanvasScene.stories.tsx index 1efcba261133..e561414a928d 100644 --- a/products/canvas/frontend/scene/CanvasScene.stories.tsx +++ b/products/canvas/frontend/scene/CanvasScene.stories.tsx @@ -6,6 +6,8 @@ import { urls } from 'scenes/urls' import { mswDecorator } from '~/mocks/browser' +import { expect, userEvent, waitFor } from 'storybook/test' + import type { CanvasApi, CanvasBuildApi, CanvasVersionApi, CanvasViewResponseApi } from '../generated/api.schemas' const CANVAS_ID = '0190aaaa-0000-7000-8000-000000000001' @@ -76,7 +78,50 @@ const liveBuild: CanvasBuildApi = { finished_at: '2026-01-02T00:01:00Z', } -function mocks(view: CanvasViewResponseApi): ReturnType { +function canvasComment( + id: string, + content: string, + createdAt: string, + itemContext: Record, + sourceComment: string | null = null +): Record { + return { + id, + content, + rich_content: null, + version: 0, + created_at: createdAt, + created_by: canvas.created_by, + scope: 'canvas', + item_id: CANVAS_ID, + item_context: itemContext, + source_comment: sourceComment, + is_task: false, + completed_at: null, + completed_by: null, + } +} + +const QUOTE = 'Weekly active users' +const comments = [ + canvasComment('comment-1', 'Can we split this by plan?', '2026-01-02T00:05:00Z', { + anchor: { kind: 'text', quote: QUOTE, prefix: '', suffix: '', start: 0, end: QUOTE.length }, + canvasVersionId: 'version-2', + taskId: TASK_ID, + }), + canvasComment( + 'comment-2', + 'Yes, I will add it to the next version.', + '2026-01-02T00:07:00Z', + { taskId: TASK_ID }, + 'comment-1' + ), +] + +function mocks( + view: CanvasViewResponseApi, + threadComments: Record[] = [] +): ReturnType { const built = !!view.published_build return mswDecorator({ get: { @@ -94,7 +139,7 @@ function mocks(view: CanvasViewResponseApi): ReturnType { results: built ? versions : [], }, '/api/projects/:team_id/canvases/:id/drafts/': [], - '/api/projects/:team_id/comments/': { next: null, previous: null, results: [] }, + '/api/projects/:team_id/comments/': { next: null, previous: null, results: threadComments }, '/api/projects/:team_id/tasks/:id/': { id: TASK_ID, title: 'Weekly active users', @@ -167,3 +212,32 @@ export const NewCanvas: Story = { }), ], } + +export const BuiltWithComments: Story = { + decorators: [ + mocks( + { + ...viewResponse({ + name: 'Weekly active users', + generation_task_id: TASK_ID, + current_version_id: 'version-2', + published_build_id: liveBuild.id, + }), + published_build: liveBuild, + current_version_id: 'version-2', + }, + comments + ), + ], + play: async ({ canvasElement }) => { + await waitFor( + async () => { + const menu = canvasElement.querySelector('[data-attr="canvas-comments-menu"]') + expect(menu).not.toBeNull() + await userEvent.click(menu!) + expect(document.querySelector('[data-attr="canvas-comments-menu-thread"]')).not.toBeNull() + }, + { timeout: 15_000 } + ) + }, +} diff --git a/products/canvas/frontend/scene/CanvasScene.tsx b/products/canvas/frontend/scene/CanvasScene.tsx index 335a15b1378b..1f8b95c390ae 100644 --- a/products/canvas/frontend/scene/CanvasScene.tsx +++ b/products/canvas/frontend/scene/CanvasScene.tsx @@ -23,6 +23,7 @@ import { CanvasBrowsedCanvas } from '../history/CanvasBrowsedCanvas' import { CanvasHistoryConfirmDialog } from '../history/CanvasHistoryConfirmDialog' import { canvasHistoryLogic } from '../history/canvasHistoryLogic' import { canvasCommentsLogic } from '../sidePanel/comments/canvasCommentsLogic' +import { CanvasCommentThreadPopover } from '../sidePanel/comments/CanvasCommentThreadPopover' import { CanvasSelectionCommentAction } from '../sidePanel/comments/CanvasSelectionCommentAction' import { CanvasEmptyBody } from './CanvasEmptyBody' import { CanvasFullscreenExit } from './CanvasFullscreenExit' @@ -160,6 +161,7 @@ function CanvasMain(): JSX.Element { )} > + ) diff --git a/products/canvas/frontend/scene/CanvasSceneHeader.tsx b/products/canvas/frontend/scene/CanvasSceneHeader.tsx index 264a975b8a85..f15c7d8c51b0 100644 --- a/products/canvas/frontend/scene/CanvasSceneHeader.tsx +++ b/products/canvas/frontend/scene/CanvasSceneHeader.tsx @@ -22,6 +22,7 @@ import { canvasSpaceLabel } from '../canvasTasksApi' import { CanvasEditSaveStatus } from '../editing/CanvasEditSaveStatus' import { CanvasEditToggle } from '../editing/CanvasEditToggle' import { CanvasVersionControls } from '../history/CanvasVersionControls' +import { CanvasCommentsMenu } from '../sidePanel/comments/CanvasCommentsMenu' import { CanvasBuildStatus } from './CanvasBuildStatus' import { CanvasFullscreenToggle } from './CanvasFullscreenToggle' import { CanvasGenerationIndicator } from './CanvasGenerationIndicator' @@ -56,6 +57,7 @@ export function CanvasSceneHeader(): JSX.Element { + diff --git a/products/canvas/frontend/sidePanel/CanvasSidePanel.tsx b/products/canvas/frontend/sidePanel/CanvasSidePanel.tsx index e0860231a5a1..8c07429bb665 100644 --- a/products/canvas/frontend/sidePanel/CanvasSidePanel.tsx +++ b/products/canvas/frontend/sidePanel/CanvasSidePanel.tsx @@ -7,9 +7,8 @@ import { canvasHistoryLogic } from '../history/canvasHistoryLogic' import { canvasSceneLogic } from '../scene/canvasSceneLogic' import { canvasPanelTab } from './canvasPanelTabs' import { CanvasSidePanelTabBody } from './CanvasSidePanelTabBody' -import { canvasCommentsLogic } from './comments/canvasCommentsLogic' -/** The canvas's tabs in the app side panel: the agent chat, comment threads, and version timeline. */ +/** The canvas's tabs in the app side panel: the agent chat, blocks, and version timeline. */ export function CanvasSidePanel(): JSX.Element | null { const { selectedTab } = useValues(sidePanelStateLogic) const { sceneSidePanelContext } = useValues(sidePanelContextLogic) @@ -22,15 +21,13 @@ export function CanvasSidePanel(): JSX.Element | null { return ( - -
- -
-
+
+ +
) diff --git a/products/canvas/frontend/sidePanel/CanvasSidePanelTabBody.tsx b/products/canvas/frontend/sidePanel/CanvasSidePanelTabBody.tsx index b90f1346a747..2f2c687ff403 100644 --- a/products/canvas/frontend/sidePanel/CanvasSidePanelTabBody.tsx +++ b/products/canvas/frontend/sidePanel/CanvasSidePanelTabBody.tsx @@ -1,7 +1,6 @@ import { CanvasBlocksTab } from './blocks/CanvasBlocksTab' import type { CanvasPanelTab } from './canvasPanelTabs' import { CanvasChatTab } from './chat/CanvasChatTab' -import { CanvasCommentsTab } from './comments/CanvasCommentsTab' import { CanvasTimelineTab } from './timeline/CanvasTimelineTab' /** The content of one side panel tab. */ @@ -11,8 +10,6 @@ export function CanvasSidePanelTabBody({ tab, canvasId }: { tab: CanvasPanelTab; return case 'blocks': return - case 'comments': - return case 'timeline': return } diff --git a/products/canvas/frontend/sidePanel/canvasPanelTabs.ts b/products/canvas/frontend/sidePanel/canvasPanelTabs.ts index 6558a082ad68..e41f4bb298fe 100644 --- a/products/canvas/frontend/sidePanel/canvasPanelTabs.ts +++ b/products/canvas/frontend/sidePanel/canvasPanelTabs.ts @@ -1,13 +1,12 @@ import { SidePanelTab } from '~/types' // pinned: panel tab keys, sent as the `tab` property of the panel_tab_change action -export type CanvasPanelTab = 'chat' | 'blocks' | 'comments' | 'timeline' +export type CanvasPanelTab = 'chat' | 'blocks' | 'timeline' /** The app side panel tab each canvas panel tab shows in. A new tab adds a row here and a case in CanvasSidePanelTabBody. */ export const CANVAS_PANEL_SIDE_PANEL_TABS: Record = { chat: SidePanelTab.CanvasChat, blocks: SidePanelTab.CanvasBlocks, - comments: SidePanelTab.CanvasComments, timeline: SidePanelTab.CanvasTimeline, } diff --git a/products/canvas/frontend/sidePanel/comments/CanvasCommentThreadCard.tsx b/products/canvas/frontend/sidePanel/comments/CanvasCommentThreadCard.tsx index 32433546776f..86d0ac79f56d 100644 --- a/products/canvas/frontend/sidePanel/comments/CanvasCommentThreadCard.tsx +++ b/products/canvas/frontend/sidePanel/comments/CanvasCommentThreadCard.tsx @@ -1,8 +1,7 @@ import { useActions, useValues } from 'kea' -import { useEffect, useRef } from 'react' -import { IconCheck, IconRefresh } from '@posthog/icons' -import { Badge, Text, ThreadItemAction, ThreadItemGroup, cn } from '@posthog/quill' +import { IconCheck, IconRefresh, IconX } from '@posthog/icons' +import { Badge, Button, Text, ThreadItemAction, ThreadItemGroup } from '@posthog/quill' import { canvasHistoryLogic } from '../../history/canvasHistoryLogic' import { CanvasCommentEntry } from './CanvasCommentEntry' @@ -10,26 +9,17 @@ import { CanvasCommentReplyForm } from './CanvasCommentReplyForm' import { canvasCommentsLogic } from './canvasCommentsLogic' import { CanvasCommentThread, isThreadStateComment } from './canvasCommentThreads' -/** One comment thread: the text it is about, its comments, and a reply box. */ +/** One comment thread over the canvas: the text it is about, its comments, and a reply box. */ export function CanvasCommentThreadCard({ thread }: { thread: CanvasCommentThread }): JSX.Element { - const { activeThreadId, writing } = useValues(canvasCommentsLogic) + const { writing } = useValues(canvasCommentsLogic) const { setActiveThread, setThreadResolved } = useActions(canvasCommentsLogic) const { versionLabels, displayedVersionId } = useValues(canvasHistoryLogic) const { setBrowseVersion } = useActions(canvasHistoryLogic) - const ref = useRef(null) - const active = activeThreadId === thread.root.id const { anchor, canvasVersionId } = thread.context const versionLabel = canvasVersionId ? versionLabels[canvasVersionId] : null const onOtherVersion = !!canvasVersionId && canvasVersionId !== displayedVersionId const replies = thread.replies.filter((reply) => !isThreadStateComment(reply)) - // A click on the anchor in the canvas brings its thread into view. - useEffect(() => { - if (active) { - ref.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) - } - }, [active]) - const resolveLabel = thread.resolved ? 'Reopen thread' : 'Resolve thread' const resolveAction = ( - {(anchor || versionLabel || thread.resolved) && ( -
- {anchor && ( - +
+
+ {anchor ? ( + + {anchor.quote} + + ) : ( + + Comment + )} {(versionLabel || thread.resolved) && (
@@ -80,18 +59,39 @@ export function CanvasCommentThreadCard({ thread }: { thread: CanvasCommentThrea {onOtherVersion ? `Left on ${versionLabel}` : `On ${versionLabel}`} )} + {onOtherVersion && canvasVersionId && ( + + )}
)}
- )} - - - {replies.map((reply) => ( - - ))} - + +
+
+ + + {replies.map((reply) => ( + + ))} + +
{!thread.resolved && ( -
+
)} diff --git a/products/canvas/frontend/sidePanel/comments/CanvasCommentThreadPopover.tsx b/products/canvas/frontend/sidePanel/comments/CanvasCommentThreadPopover.tsx new file mode 100644 index 000000000000..4d0c5c090ddb --- /dev/null +++ b/products/canvas/frontend/sidePanel/comments/CanvasCommentThreadPopover.tsx @@ -0,0 +1,92 @@ +import { useActions, useValues } from 'kea' +import { useEffect, useRef } from 'react' + +import { cn } from '@posthog/quill' + +import { canvasCommentsLogic } from './canvasCommentsLogic' +import { CanvasCommentThreadCard } from './CanvasCommentThreadCard' + +const POPOVER_WIDTH_PX = 320 +const POPOVER_MAX_HEIGHT_PX = 480 +const POPOVER_GAP_PX = 6 +const VIEWPORT_MARGIN_PX = 8 + +export function CanvasCommentThreadPopover(): JSX.Element | null { + const { activeThreadId, activeThreadRect, threads } = useValues(canvasCommentsLogic) + const { setActiveThread } = useActions(canvasCommentsLogic) + const ref = useRef(null) + const thread = threads?.find((candidate) => candidate.root.id === activeThreadId) ?? null + const open = !!thread + + useEffect(() => { + if (!open) { + return + } + ref.current?.focus({ preventScroll: true }) + const close = (): void => setActiveThread(null) + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key === 'Escape') { + close() + } + } + const onPointerDown = (event: PointerEvent): void => { + const target = event.target + if ( + target instanceof Element && + (ref.current?.contains(target) || + target.closest('[data-quill-portal], [data-attr="canvas-comments-menu"]')) + ) { + return + } + close() + } + window.addEventListener('keydown', onKeyDown) + window.addEventListener('blur', close) + window.addEventListener('resize', close) + document.addEventListener('pointerdown', onPointerDown) + return () => { + window.removeEventListener('keydown', onKeyDown) + window.removeEventListener('blur', close) + window.removeEventListener('resize', close) + document.removeEventListener('pointerdown', onPointerDown) + } + }, [open, activeThreadId, setActiveThread]) + + if (!thread) { + return null + } + + let position: React.CSSProperties | undefined + if (activeThreadRect) { + const width = Math.min(POPOVER_WIDTH_PX, window.innerWidth - VIEWPORT_MARGIN_PX * 2) + const left = Math.max( + VIEWPORT_MARGIN_PX, + Math.min(activeThreadRect.left, window.innerWidth - width - VIEWPORT_MARGIN_PX) + ) + const spaceBelow = window.innerHeight - activeThreadRect.bottom + position = + spaceBelow < POPOVER_MAX_HEIGHT_PX && activeThreadRect.top > spaceBelow + ? { left, width, bottom: window.innerHeight - activeThreadRect.top + POPOVER_GAP_PX } + : { left, width, top: activeThreadRect.bottom + POPOVER_GAP_PX } + } + + return ( +
+ +
+ ) +} diff --git a/products/canvas/frontend/sidePanel/comments/CanvasCommentsMenu.tsx b/products/canvas/frontend/sidePanel/comments/CanvasCommentsMenu.tsx new file mode 100644 index 000000000000..6df7a8a7386b --- /dev/null +++ b/products/canvas/frontend/sidePanel/comments/CanvasCommentsMenu.tsx @@ -0,0 +1,225 @@ +import { useActions, useValues } from 'kea' +import { useState } from 'react' + +import { IconComment, IconWarning } from '@posthog/icons' +import { + Avatar, + AvatarFallback, + Badge, + Button, + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, + Label, + Popover, + PopoverContent, + PopoverTrigger, + Skeleton, + SkeletonText, + Switch, + Text, + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@posthog/quill' + +import { dayjs } from 'lib/dayjs' +import { fullNameOrEmail } from 'lib/utils/strings' + +import { canvasHistoryLogic } from '../../history/canvasHistoryLogic' +import { canvasCommentsLogic } from './canvasCommentsLogic' +import { CanvasCommentThread, isThreadStateComment } from './canvasCommentThreads' + +function ThreadRow({ thread, onOpen }: { thread: CanvasCommentThread; onOpen: () => void }): JSX.Element { + const { versionLabels, displayedVersionId } = useValues(canvasHistoryLogic) + const name = thread.root.created_by ? fullNameOrEmail(thread.root.created_by) : 'Someone' + const replyCount = thread.replies.filter((reply) => !isThreadStateComment(reply)).length + const { anchor, canvasVersionId } = thread.context + const versionLabel = + canvasVersionId && canvasVersionId !== displayedVersionId ? versionLabels[canvasVersionId] : null + return ( + + ) +} + +function MenuBody({ onOpenThread }: { onOpenThread: (thread: CanvasCommentThread) => void }): JSX.Element { + const { visibleThreads, threads, commentsLoadFailed, commentsLoading } = useValues(canvasCommentsLogic) + const { loadComments } = useActions(canvasCommentsLogic) + + if (!threads) { + if (commentsLoadFailed) { + return ( + + + + + + Comments didn't load + Check your connection and try again. + + + + + + ) + } + return ( +
+ {[0, 1].map((index) => ( +
+ + +
+ ))} +
+ ) + } + if (!visibleThreads || visibleThreads.length === 0) { + return ( + + + + + + {threads.length > 0 ? 'No open comments' : 'No comments yet'} + + Select text in the canvas, then choose Comment to start a thread. + + + + ) + } + return ( +
+ {visibleThreads.map((thread) => ( + onOpenThread(thread)} /> + ))} +
+ ) +} + +export function CanvasCommentsMenu(): JSX.Element | null { + const { commentsEnabled, threads, resolvedCount, showResolved, displayedVersionId } = useValues(canvasCommentsLogic) + const { setShowResolved, activateThread } = useActions(canvasCommentsLogic) + const { setBrowseVersion } = useActions(canvasHistoryLogic) + const [open, setOpen] = useState(false) + + if (!commentsEnabled) { + return null + } + const openCount = threads?.filter((thread) => !thread.resolved).length ?? 0 + const label = openCount > 0 ? `Comments, ${openCount} open` : 'Comments' + const openThread = (thread: CanvasCommentThread): void => { + setOpen(false) + const { canvasVersionId } = thread.context + if (canvasVersionId && canvasVersionId !== displayedVersionId) { + setBrowseVersion(canvasVersionId) + } + activateThread(thread.root.id, null, 'menu') + } + + return ( + + + }> + 0 ? 'sm' : 'icon-sm'} + variant="default" + aria-label={label} + data-attr="canvas-comments-menu" + /> + } + > + + {openCount > 0 && {openCount}} + + + Comments + + +
+ }> + Comments + + {resolvedCount > 0 && ( +
+ setShowResolved(checked)} + data-attr="canvas-comments-show-resolved" + /> + +
+ )} +
+ +
+
+ ) +} diff --git a/products/canvas/frontend/sidePanel/comments/CanvasCommentsTab.tsx b/products/canvas/frontend/sidePanel/comments/CanvasCommentsTab.tsx deleted file mode 100644 index 28236c0fa93a..000000000000 --- a/products/canvas/frontend/sidePanel/comments/CanvasCommentsTab.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import { useActions, useValues } from 'kea' -import { Fragment } from 'react' - -import { IconComment, IconWarning } from '@posthog/icons' -import { - Button, - Empty, - EmptyContent, - EmptyDescription, - EmptyHeader, - EmptyMedia, - EmptyTitle, - Label, - Separator, - Skeleton, - SkeletonText, - Switch, - Text, -} from '@posthog/quill' - -import { canvasCommentsLogic } from './canvasCommentsLogic' -import { CanvasCommentThreadCard } from './CanvasCommentThreadCard' - -/** The Comments tab: the canvas's comment threads, newest first. */ -export function CanvasCommentsTab(): JSX.Element { - const { - visibleThreads, - threads, - resolvedCount, - showResolved, - commentsLoadFailed, - commentsLoading, - commentsEnabled, - } = useValues(canvasCommentsLogic) - const { setShowResolved, loadComments } = useActions(canvasCommentsLogic) - - if (!commentsEnabled) { - return ( - - - - - - No comments yet - Comments open once an agent has built this canvas. - - - ) - } - if (!threads) { - if (commentsLoadFailed) { - return ( - - - - - - Comments didn't load - Check your connection and try again. - - - - - - ) - } - return ( -
- {[0, 1].map((index) => ( -
- - -
- ))} -
- ) - } - - return ( -
-
- - Select text in the canvas to comment on it. - - {resolvedCount > 0 && ( -
- setShowResolved(checked)} - data-attr="canvas-comments-show-resolved" - /> - -
- )} -
- {visibleThreads && visibleThreads.length > 0 ? ( -
- {visibleThreads.map((thread, index) => ( - - {index > 0 && } - - - ))} -
- ) : ( - - - - - - {threads.length > 0 ? 'No open comments' : 'No comments yet'} - - Select text in the canvas, then choose Comment to start a thread. - - - - )} -
- ) -} diff --git a/products/canvas/frontend/sidePanel/comments/canvasCommentsLogic.ts b/products/canvas/frontend/sidePanel/comments/canvasCommentsLogic.ts index 3d0b9a2f85cf..f614ac3d4d4d 100644 --- a/products/canvas/frontend/sidePanel/comments/canvasCommentsLogic.ts +++ b/products/canvas/frontend/sidePanel/comments/canvasCommentsLogic.ts @@ -12,9 +12,8 @@ import type { CommentType } from '~/types' import { captureCanvasAction } from '../../canvasAnalytics' import type { CanvasApi, CanvasVersionApi } from '../../generated/api.schemas' import { canvasHistoryLogic } from '../../history/canvasHistoryLogic' -import type { CanvasCommentHighlight, CanvasTextSelection } from '../../host/canvasProtocol' +import type { CanvasCommentHighlight, CanvasRect, CanvasTextSelection } from '../../host/canvasProtocol' import { canvasSceneLogic } from '../../scene/canvasSceneLogic' -import { canvasSidePanelLogic } from '../canvasSidePanelLogic' import { canvasCommentTaskId } from '../chat/canvasChatTask' import { CANVAS_COMMENT_SCOPE, @@ -32,6 +31,8 @@ export interface CanvasCommentsLogicProps { id: string } +export type CanvasThreadSource = 'highlight' | 'menu' | 'created' + async function loadAllCanvasComments(canvasId: string): Promise { // The comments API has no generated client this product can import, so this goes through lib/api. const comments: CommentType[] = [] @@ -54,6 +55,7 @@ export interface canvasCommentsLogicValues { canvas: CanvasApi | null // canvasSceneLogic featureFlags: FeatureFlagsSet // featureFlagLogic activeThreadId: string | null + activeThreadRect: CanvasRect | null clearTextSelectionKey: number commentTaskId: string | null comments: CommentType[] | null @@ -74,15 +76,14 @@ export interface canvasCommentsLogicValues { // Generated by kea-typegen. Update if you're an agent, ignore if you're human. export interface canvasCommentsLogicActions { - openTab: ( - tab: import('../canvasPanelTabs').CanvasPanelTab, - canvasId: string + activateThread: ( + id: string, + rect: CanvasRect | null, + source: CanvasThreadSource ) => { - canvasId: string - tab: import('../canvasPanelTabs').CanvasPanelTab - } // canvasSidePanelLogic - activateThread: (id: string) => { id: string + rect: CanvasRect | null + source: CanvasThreadSource } createComment: () => { value: true @@ -180,7 +181,7 @@ export type canvasCommentsLogicType = MakeLogicType< > /** - * Comment threads on a canvas: listing and replying in the side panel, commenting on a text + * Comment threads on a canvas: the thread open over the canvas, the comments menu, commenting on a text * selection in the frame, and the highlights the frame draws for each open thread. */ export const canvasCommentsLogic = kea([ @@ -196,14 +197,13 @@ export const canvasCommentsLogic = kea([ featureFlagLogic, ['featureFlags'], ], - actions: [canvasSidePanelLogic, ['openTab']], })), actions({ openSelectionComposer: true, setTextSelection: (selection: CanvasTextSelection | null) => ({ selection }), dismissTextSelection: true, setActiveThread: (id: string | null) => ({ id }), - activateThread: (id: string) => ({ id }), + activateThread: (id: string, rect: CanvasRect | null, source: CanvasThreadSource) => ({ id, rect, source }), setShowResolved: (showResolved: boolean) => ({ showResolved }), createComment: true, replyToThread: (rootId: string) => ({ rootId }), @@ -256,6 +256,13 @@ export const canvasCommentsLogic = kea([ activateThread: (_, { id }) => id, }, ], + activeThreadRect: [ + null as CanvasRect | null, + { + setActiveThread: () => null, + activateThread: (_, { rect }) => rect, + }, + ], // Unsent text stays when a write fails, so the viewer can send it again. replyDrafts: [ {} as Record, @@ -360,7 +367,7 @@ export const canvasCommentsLogic = kea([ captureCanvasAction(actionType, { dashboard_id: props.id, channel_id: values.canvas?.channel, - surface: 'web_canvas_side_panel', + surface: 'web_canvas_scene', success, }) const write = async ( @@ -390,8 +397,15 @@ export const canvasCommentsLogic = kea([ } } return { - activateThread: () => { - actions.openTab('comments', props.id) + activateThread: ({ source }) => { + if (source !== 'created') { + captureCanvasAction('comment_open', { + dashboard_id: props.id, + channel_id: values.canvas?.channel, + surface: 'web_canvas_scene', + source, + }) + } }, createComment: async () => { const content = values.selectionDraft @@ -408,8 +422,7 @@ export const canvasCommentsLogic = kea([ }), }) if (saved) { - actions.openTab('comments', props.id) - actions.setActiveThread(saved.id) + actions.activateThread(saved.id, selection.rect, 'created') } }, replyToThread: async ({ rootId }) => { From 83ef09782feafeff1353255b9d4c496a72458c70 Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Fri, 2 Oct 2026 15:22:11 +0200 Subject: [PATCH 15/48] feat(tasks): show artifact comment threads inline A click on a text highlight or an image pin opens the thread in a popover next to it. The comments button opens a menu with comments on the whole file and a list of every thread. The comments side panel is removed. Co-Authored-By: Claude Opus 5.5 Generated-By: PostHog Desktop Task-Id: b999fc3e-c145-4e0e-9851-6ef273201d04 --- .../TaskTracker/TaskRunArtifacts.stories.tsx | 38 +++ .../components/ArtifactCommentActions.tsx | 36 +-- .../components/ArtifactCommentThreadCard.tsx | 72 ++++- .../components/ArtifactCommentsMenu.tsx | 266 ++++++++++++++++++ .../components/ArtifactCommentsPanel.tsx | 170 ----------- .../components/ArtifactImagePins.tsx | 38 ++- .../components/ArtifactInlineThread.tsx | 74 +++++ .../components/ArtifactTextAnnotations.tsx | 16 +- .../components/TaskRunArtifacts.tsx | 5 - .../TaskTracker/taskArtifactCommentsLogic.ts | 46 +-- 10 files changed, 522 insertions(+), 239 deletions(-) create mode 100644 products/posthog_ai/frontend/scenes/TaskTracker/components/ArtifactCommentsMenu.tsx delete mode 100644 products/posthog_ai/frontend/scenes/TaskTracker/components/ArtifactCommentsPanel.tsx create mode 100644 products/posthog_ai/frontend/scenes/TaskTracker/components/ArtifactInlineThread.tsx diff --git a/products/posthog_ai/frontend/scenes/TaskTracker/TaskRunArtifacts.stories.tsx b/products/posthog_ai/frontend/scenes/TaskTracker/TaskRunArtifacts.stories.tsx index 8994a9be02a1..fc73f99114da 100644 --- a/products/posthog_ai/frontend/scenes/TaskTracker/TaskRunArtifacts.stories.tsx +++ b/products/posthog_ai/frontend/scenes/TaskTracker/TaskRunArtifacts.stories.tsx @@ -20,6 +20,8 @@ import type { } from 'products/tasks/frontend/generated/api.schemas' import { TaskRuntimeEnumApi } from 'products/tasks/frontend/generated/api.schemas' +import { expect, userEvent, waitFor } from 'storybook/test' + import { OriginProduct, Task, TaskRun, TaskRunEnvironment, TaskRunStatus } from '../../types/taskTypes' import { TaskDetailPage } from './components/TaskDetailPage' import { TaskRunTab } from './taskRunArtifacts' @@ -851,3 +853,39 @@ export const ImageCommentPins: Story = { parameters: { msw: { mocks: commentMocks() } }, render: () => , } + +export const MarkdownCommentThread: Story = { + parameters: { msw: { mocks: commentMocks() } }, + render: () => , + play: async ({ canvasElement }) => { + const highlight = await waitFor( + () => { + const element = canvasElement.querySelector( + '[data-attr="task-artifact-comment-highlight"]' + ) + expect(element).not.toBeNull() + return element! + }, + { timeout: 10_000 } + ) + await userEvent.click(highlight) + }, +} + +export const ImageCommentThread: Story = { + parameters: { msw: { mocks: commentMocks() } }, + render: () => , + play: async ({ canvasElement }) => { + const pin = await waitFor( + () => { + const element = canvasElement.querySelector( + '[data-attr="task-artifact-comment-pin-marker"]' + ) + expect(element).not.toBeNull() + return element! + }, + { timeout: 10_000 } + ) + await userEvent.click(pin) + }, +} diff --git a/products/posthog_ai/frontend/scenes/TaskTracker/components/ArtifactCommentActions.tsx b/products/posthog_ai/frontend/scenes/TaskTracker/components/ArtifactCommentActions.tsx index f6149b2b859f..a5b7c4d95971 100644 --- a/products/posthog_ai/frontend/scenes/TaskTracker/components/ArtifactCommentActions.tsx +++ b/products/posthog_ai/frontend/scenes/TaskTracker/components/ArtifactCommentActions.tsx @@ -1,18 +1,15 @@ import { useActions, useValues } from 'kea' -import { IconComment, IconPin } from '@posthog/icons' -import { Button, Text, Tooltip, TooltipContent, TooltipTrigger, cn } from '@posthog/quill-primitives' +import { IconPin } from '@posthog/icons' +import { Button, Tooltip, TooltipContent, TooltipTrigger, cn } from '@posthog/quill-primitives' import { TaskArtifactCommentsLogicProps, taskArtifactCommentsLogic } from '../taskArtifactCommentsLogic' -import { taskRunArtifactsLogic } from '../taskRunArtifactsLogic' +import { ArtifactCommentsMenu } from './ArtifactCommentsMenu' -/** The toolbar controls for comments: show the panel, and on an image, pin a comment to a spot. */ +/** The toolbar controls for comments: the comments menu, and on an image, pin a comment to a spot. */ export function ArtifactCommentActions({ logicProps }: { logicProps: TaskArtifactCommentsLogicProps }): JSX.Element { - const { openCount, pinMode } = useValues(taskArtifactCommentsLogic(logicProps)) + const { pinMode } = useValues(taskArtifactCommentsLogic(logicProps)) const { setPinMode } = useActions(taskArtifactCommentsLogic(logicProps)) - const { commentsOpen } = useValues(taskRunArtifactsLogic({ taskId: logicProps.taskId })) - const { setCommentsOpen } = useActions(taskRunArtifactsLogic({ taskId: logicProps.taskId })) - const commentsLabel = commentsOpen ? 'Hide comments' : 'Show comments' const pinLabel = pinMode ? 'Stop pinning' : 'Pin a comment to a spot on the image' return ( <> @@ -37,28 +34,7 @@ export function ArtifactCommentActions({ logicProps }: { logicProps: TaskArtifac {pinLabel} )} - - 0 ? `${commentsLabel}, ${openCount} open` : commentsLabel} - aria-pressed={commentsOpen} - className={cn(commentsOpen && 'bg-fill-selected')} - onClick={() => setCommentsOpen(!commentsOpen)} - data-attr="task-artifact-comments-toggle" - /> - } - > - - {openCount > 0 && ( - } className="tabular-nums"> - {openCount} - - )} - - {commentsLabel} - + ) } diff --git a/products/posthog_ai/frontend/scenes/TaskTracker/components/ArtifactCommentThreadCard.tsx b/products/posthog_ai/frontend/scenes/TaskTracker/components/ArtifactCommentThreadCard.tsx index 5f25931448b3..83301b266850 100644 --- a/products/posthog_ai/frontend/scenes/TaskTracker/components/ArtifactCommentThreadCard.tsx +++ b/products/posthog_ai/frontend/scenes/TaskTracker/components/ArtifactCommentThreadCard.tsx @@ -1,7 +1,7 @@ import { useActions, useValues } from 'kea' import { useEffect, useRef } from 'react' -import { IconCheck, IconPin, IconRefresh } from '@posthog/icons' +import { IconCheck, IconPin, IconRefresh, IconX } from '@posthog/icons' import { Badge, Button, Text, ThreadItemAction, ThreadItemGroup, cn } from '@posthog/quill-primitives' import { ArtifactCommentThread } from '../artifactComments' @@ -13,9 +13,11 @@ import { ArtifactCommentEntry } from './ArtifactCommentEntry' export function ArtifactCommentThreadCard({ logicProps, thread, + inline = false, }: { logicProps: TaskArtifactCommentsLogicProps thread: ArtifactCommentThread + inline?: boolean }): JSX.Element { const { activeThreadId, writing, drafts } = useValues(taskArtifactCommentsLogic(logicProps)) const { activateThread, setThreadResolved, replyToThread, setDraft } = useActions( @@ -28,10 +30,10 @@ export function ArtifactCommentThreadCard({ // A click on a highlight or a pin in the preview brings its thread into view. useEffect(() => { - if (active) { + if (active && !inline) { ref.current?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) } - }, [active]) + }, [active, inline]) const resolveAction = ( +
+
+ {anchor?.kind === 'text' && ( + } + className="line-clamp-2 w-full border-l-2 border-warning pl-2 italic" + > + {anchor.quote} + + )} + {thread.pinNumber && ( + + + {`Pin ${thread.pinNumber}`} + + )} + {thread.resolved && Resolved} +
+ +
+
+ + + {thread.replies.map((reply) => ( + + ))} + +
+ {!thread.resolved && ( +
+ setDraft(rootId, value)} + onSubmit={() => replyToThread(rootId)} + saving={writing === rootId} + busy={!!writing && writing !== rootId} + label="Reply" + placeholder="Reply" + submitLabel="Reply" + rows={2} + dataAttr="task-artifact-comment-reply" + /> +
+ )} + + ) + } + return (
void }): JSX.Element { + const name = thread.root.created_by ? fullNameOrEmail(thread.root.created_by) : 'Deleted user' + const { anchor } = thread + return ( + + ) +} + +function ThreadList({ logicProps }: { logicProps: TaskArtifactCommentsLogicProps }): JSX.Element { + const { visibleThreads, threads, commentsLoadFailed, commentsLoading } = useValues( + taskArtifactCommentsLogic(logicProps) + ) + const { loadComments, activateThread } = useActions(taskArtifactCommentsLogic(logicProps)) + const { setCommentsOpen } = useActions(taskRunArtifactsLogic({ taskId: logicProps.taskId })) + if (!threads) { + if (commentsLoadFailed) { + return ( + + + + + + Comments didn't load + Check your connection and try again. + + + + + + ) + } + return ( +
+ {[0, 1].map((index) => ( +
+ + +
+ ))} +
+ ) + } + if (!visibleThreads || visibleThreads.length === 0) { + return ( + + + + + + {threads.length > 0 ? 'No open comments' : 'No comments yet'} + {emptyHint(logicProps.kind)} + + + ) + } + return ( +
+ {visibleThreads.map((thread, index) => ( + + {index > 0 && } + {thread.anchor && thread.anchor.kind !== 'document' ? ( + { + setCommentsOpen(false) + activateThread(thread.root.id, 'menu') + }} + /> + ) : ( + + )} + + ))} +
+ ) +} + +export function ArtifactCommentsMenu({ logicProps }: { logicProps: TaskArtifactCommentsLogicProps }): JSX.Element { + const { openCount, resolvedCount, showResolved, drafts, writing } = useValues(taskArtifactCommentsLogic(logicProps)) + const { setShowResolved, setDraft, submitComment } = useActions(taskArtifactCommentsLogic(logicProps)) + const { commentsOpen } = useValues(taskRunArtifactsLogic({ taskId: logicProps.taskId })) + const { setCommentsOpen } = useActions(taskRunArtifactsLogic({ taskId: logicProps.taskId })) + const label = openCount > 0 ? `Comments, ${openCount} open` : 'Comments' + const switchId = `task-artifact-comments-show-resolved-${logicProps.artifactId}` + return ( + setCommentsOpen(open)}> + + }> + + } + > + + {openCount > 0 && ( + } className="tabular-nums"> + {openCount} + + )} + + + Comments + + +
+ }> + Comments + + {resolvedCount > 0 && ( +
+ setShowResolved(checked)} + data-attr="task-artifact-comments-show-resolved" + /> + +
+ )} +
+
+ setDraft('document', value)} + onSubmit={() => submitComment('document')} + saving={writing === 'document'} + busy={!!writing && writing !== 'document'} + label="Comment on this file" + placeholder="Comment on this file" + dataAttr="task-artifact-comment-document" + /> +
+ +
+
+ ) +} diff --git a/products/posthog_ai/frontend/scenes/TaskTracker/components/ArtifactCommentsPanel.tsx b/products/posthog_ai/frontend/scenes/TaskTracker/components/ArtifactCommentsPanel.tsx deleted file mode 100644 index 86f3ce84ffe5..000000000000 --- a/products/posthog_ai/frontend/scenes/TaskTracker/components/ArtifactCommentsPanel.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import { useActions, useValues } from 'kea' -import { Fragment } from 'react' - -import { IconComment, IconWarning, IconX } from '@posthog/icons' -import { - Button, - Empty, - EmptyContent, - EmptyDescription, - EmptyHeader, - EmptyMedia, - EmptyTitle, - Label, - Separator, - Skeleton, - SkeletonText, - Switch, - Text, - Tooltip, - TooltipContent, - TooltipTrigger, -} from '@posthog/quill-primitives' - -import { supportsSelectionComments } from '../artifactComments' -import { TaskArtifactCommentsLogicProps, taskArtifactCommentsLogic } from '../taskArtifactCommentsLogic' -import { taskRunArtifactsLogic } from '../taskRunArtifactsLogic' -import { ArtifactCommentComposer } from './ArtifactCommentComposer' -import { ArtifactCommentThreadCard } from './ArtifactCommentThreadCard' - -function emptyHint(kind: TaskArtifactCommentsLogicProps['kind']): string { - if (kind === 'image') { - return 'Comment on the whole image above, or pin a comment to a spot on it.' - } - if (supportsSelectionComments(kind)) { - return 'Comment on the whole file above, or select text in the preview to comment on it.' - } - return 'Comment on the whole file above.' -} - -function ThreadList({ logicProps }: { logicProps: TaskArtifactCommentsLogicProps }): JSX.Element { - const { visibleThreads, threads, commentsLoadFailed, commentsLoading } = useValues( - taskArtifactCommentsLogic(logicProps) - ) - const { loadComments } = useActions(taskArtifactCommentsLogic(logicProps)) - if (!threads) { - if (commentsLoadFailed) { - return ( - - - - - - Comments didn't load - Check your connection and try again. - - - - - - ) - } - return ( -
- {[0, 1].map((index) => ( -
- - -
- ))} -
- ) - } - if (!visibleThreads || visibleThreads.length === 0) { - return ( - - - - - - {threads.length > 0 ? 'No open comments' : 'No comments yet'} - {emptyHint(logicProps.kind)} - - - ) - } - return ( -
- {visibleThreads.map((thread, index) => ( - - {index > 0 && } - - - ))} -
- ) -} - -/** The comment threads on the open artifact version, with a box to comment on the whole file. */ -export function ArtifactCommentsPanel({ logicProps }: { logicProps: TaskArtifactCommentsLogicProps }): JSX.Element { - const { resolvedCount, showResolved, drafts, writing } = useValues(taskArtifactCommentsLogic(logicProps)) - const { setShowResolved, setDraft, submitComment } = useActions(taskArtifactCommentsLogic(logicProps)) - const { setCommentsOpen } = useActions(taskRunArtifactsLogic({ taskId: logicProps.taskId })) - const switchId = `task-artifact-comments-show-resolved-${logicProps.artifactId}` - return ( - - ) -} diff --git a/products/posthog_ai/frontend/scenes/TaskTracker/components/ArtifactImagePins.tsx b/products/posthog_ai/frontend/scenes/TaskTracker/components/ArtifactImagePins.tsx index 46cdd30f01f7..2b62c302aea2 100644 --- a/products/posthog_ai/frontend/scenes/TaskTracker/components/ArtifactImagePins.tsx +++ b/products/posthog_ai/frontend/scenes/TaskTracker/components/ArtifactImagePins.tsx @@ -1,5 +1,5 @@ import { useActions, useValues } from 'kea' -import { useEffect, useRef } from 'react' +import { CSSProperties, useEffect, useRef } from 'react' import { cn } from '@posthog/quill-primitives' @@ -7,6 +7,7 @@ import { fullNameOrEmail } from 'lib/utils/strings' import type { RegionCommentAnchor } from '../artifactComments' import { TaskArtifactCommentsLogicProps, taskArtifactCommentsLogic } from '../taskArtifactCommentsLogic' +import { ArtifactInlineThread } from './ArtifactInlineThread' import { ArtifactPendingComment } from './ArtifactPendingComment' /** The point of a pin sits on its region's bottom left corner, the same spot Desktop draws it on. */ @@ -14,6 +15,19 @@ function pinStyle(anchor: RegionCommentAnchor): { left: string; top: string } { return { left: `${anchor.x * 100}%`, top: `${(anchor.y + anchor.height) * 100}%` } } +function besidePinStyle(region: RegionCommentAnchor): CSSProperties { + return { + ...(region.y < 0.5 + ? { top: `${(region.y + region.height) * 100}%` } + : { bottom: `${(1 - region.y - region.height) * 100}%` }), + ...(region.x < 0.5 ? { left: `${region.x * 100}%` } : { right: `${(1 - region.x - region.width) * 100}%` }), + } +} + +function besidePinClassName(region: RegionCommentAnchor): string { + return cn('pointer-events-auto', region.y < 0.5 ? 'mt-1' : 'mb-8') +} + function PinMarker({ label, number, @@ -57,7 +71,7 @@ export function ArtifactImagePins({ logicProps }: { logicProps: TaskArtifactComm const { activateThread } = useActions(taskArtifactCommentsLogic(logicProps)) const rootRef = useRef(null) - // A pick in the comments panel scrolls its pin into view when the image is zoomed in. + // A pick in the comments menu scrolls its pin into view when the image is zoomed in. useEffect(() => { if (activeThreadId) { rootRef.current @@ -67,6 +81,8 @@ export function ArtifactImagePins({ logicProps }: { logicProps: TaskArtifactComm }, [activeThreadId]) const pendingRegion = pendingAnchor?.kind === 'region' ? pendingAnchor : null + const activeAnchor = anchoredThreads.find((thread) => thread.root.id === activeThreadId)?.anchor + const activeRegion = activeAnchor?.kind === 'region' ? activeAnchor : null return (
{anchoredThreads.map((thread) => { @@ -91,19 +107,19 @@ export function ArtifactImagePins({ logicProps }: { logicProps: TaskArtifactComm )} + {activeRegion && !pendingRegion && ( + + )}
) } diff --git a/products/posthog_ai/frontend/scenes/TaskTracker/components/ArtifactInlineThread.tsx b/products/posthog_ai/frontend/scenes/TaskTracker/components/ArtifactInlineThread.tsx new file mode 100644 index 000000000000..27ef144174b2 --- /dev/null +++ b/products/posthog_ai/frontend/scenes/TaskTracker/components/ArtifactInlineThread.tsx @@ -0,0 +1,74 @@ +import { useActions, useValues } from 'kea' +import { CSSProperties, useEffect, useRef } from 'react' + +import { cn } from '@posthog/quill-primitives' + +import { TaskArtifactCommentsLogicProps, taskArtifactCommentsLogic } from '../taskArtifactCommentsLogic' +import { ArtifactCommentThreadCard } from './ArtifactCommentThreadCard' + +export const INLINE_THREAD_WIDTH_PX = 320 + +const KEEP_OPEN_SELECTOR = + '[data-quill-portal], [data-attr="task-artifact-comment-highlight"], [data-attr="task-artifact-comment-pin-marker"], [data-attr="task-artifact-comments-toggle"]' + +export function ArtifactInlineThread({ + logicProps, + style, + className, +}: { + logicProps: TaskArtifactCommentsLogicProps + style: CSSProperties + className?: string +}): JSX.Element | null { + const { activeThreadId, threads } = useValues(taskArtifactCommentsLogic(logicProps)) + const { activateThread } = useActions(taskArtifactCommentsLogic(logicProps)) + const ref = useRef(null) + const thread = threads?.find((candidate) => candidate.root.id === activeThreadId) ?? null + const open = !!thread + + useEffect(() => { + if (!open) { + return + } + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key === 'Escape') { + activateThread(null) + } + } + const onPointerDown = (event: PointerEvent): void => { + const target = event.target + if (target instanceof Element && (ref.current?.contains(target) || target.closest(KEEP_OPEN_SELECTOR))) { + return + } + activateThread(null) + } + window.addEventListener('keydown', onKeyDown) + document.addEventListener('pointerdown', onPointerDown) + return () => { + window.removeEventListener('keydown', onKeyDown) + document.removeEventListener('pointerdown', onPointerDown) + } + }, [open, activateThread]) + + if (!thread) { + return null + } + return ( +
event.stopPropagation()} + onClick={(event) => event.stopPropagation()} + data-attr="task-artifact-comment-inline-thread" + > + +
+ ) +} diff --git a/products/posthog_ai/frontend/scenes/TaskTracker/components/ArtifactTextAnnotations.tsx b/products/posthog_ai/frontend/scenes/TaskTracker/components/ArtifactTextAnnotations.tsx index b575fe8d0e51..386fbfd8c720 100644 --- a/products/posthog_ai/frontend/scenes/TaskTracker/components/ArtifactTextAnnotations.tsx +++ b/products/posthog_ai/frontend/scenes/TaskTracker/components/ArtifactTextAnnotations.tsx @@ -7,6 +7,7 @@ import { fullNameOrEmail } from 'lib/utils/strings' import { createTextCommentAnchor, resolveTextCommentAnchor } from '../artifactComments' import { TaskArtifactCommentsLogicProps, taskArtifactCommentsLogic } from '../taskArtifactCommentsLogic' +import { ArtifactInlineThread, INLINE_THREAD_WIDTH_PX } from './ArtifactInlineThread' import { ArtifactPendingComment, PENDING_COMMENT_WIDTH_PX } from './ArtifactPendingComment' const EDGE_MARGIN_PX = 8 @@ -176,7 +177,7 @@ export function ArtifactTextAnnotations({ } }, []) - // A pick in the comments panel scrolls its quote into view. + // A pick in the comments menu scrolls its quote into view. useEffect(() => { const root = rootRef.current const thread = textThreads.find((candidate) => candidate.root.id === activeThreadId) @@ -256,6 +257,10 @@ export function ArtifactTextAnnotations({ } }) + const activeEnd = rects.filter((rect) => rect.id === activeThreadId).at(-1) + const pendingText = pendingAnchor?.kind === 'text' && !!pendingPosition + const maxThreadLeft = (containerRef.current?.clientWidth ?? 0) - INLINE_THREAD_WIDTH_PX - EDGE_MARGIN_PX + return (
{children}
@@ -281,6 +286,15 @@ export function ArtifactTextAnnotations({ /> ))}
+ {activeEnd && !pendingText && ( + + )} {pendingAnchor?.kind === 'text' && pendingPosition && (
- {comments && commentsOpen && }
) } diff --git a/products/posthog_ai/frontend/scenes/TaskTracker/taskArtifactCommentsLogic.ts b/products/posthog_ai/frontend/scenes/TaskTracker/taskArtifactCommentsLogic.ts index 36b80c8753a7..6334961079d1 100644 --- a/products/posthog_ai/frontend/scenes/TaskTracker/taskArtifactCommentsLogic.ts +++ b/products/posthog_ai/frontend/scenes/TaskTracker/taskArtifactCommentsLogic.ts @@ -18,7 +18,6 @@ import { buildArtifactCommentThreads, } from './artifactComments' import type { ArtifactPreviewKind } from './taskRunArtifacts' -import { taskRunArtifactsLogic } from './taskRunArtifactsLogic' const COMMENTS_POLL_INTERVAL_MS = 10_000 // The comments list pages at 100. This many pages covers any real artifact, the same cap as Desktop. @@ -34,6 +33,8 @@ export interface TaskArtifactCommentsLogicProps { /** What a draft or a write is for: a new comment on the whole file, the pending pin or selection, or a thread. */ export type CommentTarget = 'document' | 'pending' | string +export type ArtifactThreadSource = 'preview' | 'menu' | 'created' + /** Where the pending selection composer sits, in pixels inside the scroll content of the preview. */ export interface PendingPosition { left: number @@ -83,11 +84,12 @@ export interface taskArtifactCommentsLogicValues { // Generated by kea-typegen. Update if you're an agent, ignore if you're human. export interface taskArtifactCommentsLogicActions { - setCommentsOpen: (open: boolean) => { - open: boolean - } // taskRunArtifactsLogic - activateThread: (id: string | null) => { + activateThread: ( + id: string | null, + source?: ArtifactThreadSource + ) => { id: string | null + source: ArtifactThreadSource } dismissPending: () => { value: true @@ -178,9 +180,8 @@ export const taskArtifactCommentsLogic = kea([ props({} as TaskArtifactCommentsLogicProps), key((props) => `${props.taskId}:${props.artifactId}`), path((key) => ['products', 'posthog_ai', 'frontend', 'scenes', 'TaskTracker', 'taskArtifactCommentsLogic', key]), - connect((props: TaskArtifactCommentsLogicProps) => ({ + connect(() => ({ values: [projectLogic, ['currentProjectId']], - actions: [taskRunArtifactsLogic({ taskId: props.taskId }), ['setCommentsOpen']], })), actions({ setDraft: (target: CommentTarget, draft: string) => ({ target, draft }), @@ -195,7 +196,7 @@ export const taskArtifactCommentsLogic = kea([ setThreadResolved: (rootId: string, resolved: boolean) => ({ rootId, resolved }), /** The write for `target` ended. `target` is null when it failed. */ writeFinished: (target: CommentTarget | null) => ({ target }), - activateThread: (id: string | null) => ({ id }), + activateThread: (id: string | null, source: ArtifactThreadSource = 'preview') => ({ id, source }), setShowResolved: (showResolved: boolean) => ({ showResolved }), }), loaders(({ props, values }) => ({ @@ -241,6 +242,7 @@ export const taskArtifactCommentsLogic = kea([ { setPendingAnchor: (_, { anchor }) => anchor, dismissPending: () => null, + activateThread: (state, { id }) => (id ? null : state), setPinMode: (state, { pinMode }) => (pinMode ? state : null), writeFinished: (state, { target }) => (target === 'pending' ? null : state), }, @@ -263,7 +265,14 @@ export const taskArtifactCommentsLogic = kea([ writeFinished: () => null, }, ], - activeThreadId: [null as string | null, { activateThread: (_, { id }) => id }], + activeThreadId: [ + null as string | null, + { + activateThread: (_, { id }) => id, + setPendingAnchor: () => null, + setPinMode: (state, { pinMode }) => (pinMode ? null : state), + }, + ], showResolved: [false, { setShowResolved: (_, { showResolved }) => showResolved }], commentsLoadFailed: [false, { loadCommentsSuccess: () => false, loadCommentsFailure: () => true }], }), @@ -326,11 +335,6 @@ export const taskArtifactCommentsLogic = kea([ } } return { - setPinMode: ({ pinMode }) => { - if (pinMode) { - actions.setCommentsOpen(true) - } - }, setPendingAnchor: ({ anchor }) => { // pinned: analytics event name and properties. Renaming them breaks insights. posthog.capture('task artifact comment started', { @@ -356,9 +360,8 @@ export const taskArtifactCommentsLogic = kea([ }) if (target === 'pending') { window.getSelection()?.removeAllRanges() + actions.activateThread(saved.id, 'created') } - actions.setCommentsOpen(true) - actions.activateThread(saved.id) } }, replyToThread: async ({ rootId }) => { @@ -402,9 +405,14 @@ export const taskArtifactCommentsLogic = kea([ }) } }, - activateThread: ({ id }) => { - if (id) { - actions.setCommentsOpen(true) + activateThread: ({ id, source }) => { + const thread = id ? values.threads?.find((candidate) => candidate.root.id === id) : null + if (thread && source !== 'created') { + posthog.capture('task artifact comment thread opened', { + anchor_kind: anchorKindLabel(thread.anchor), + kind: props.kind, + source, + }) } }, loadCommentsSuccess: () => schedulePoll(), From a6a1bf934e0bd930407a8e30435190ef1573e11f Mon Sep 17 00:00:00 2001 From: Radu Raicea Date: Fri, 2 Oct 2026 09:26:13 -0400 Subject: [PATCH 16/48] fix(aio): address bounded provider review feedback --- .../internal/ai-observability-judge-inputs.md | 4 +- ee/hogai/utils/asgi.py | 34 +++- ee/hogai/utils/test/test_asgi.py | 54 ++++++ posthog/api/streaming.py | 13 +- posthog/api/test/test_streaming.py | 11 +- posthog/security/bounded_httpx.py | 19 +- .../ai_observability/evaluation_llm_judge.py | 4 +- .../temporal/ai_observability/run_tagger.py | 16 +- .../ai_observability/test_run_evaluation.py | 25 ++- .../ai_observability/test_run_tagger.py | 71 ++++++- posthog/temporal/common/posthog_client.py | 1 + .../ai_observability/backend/api/proxy.py | 14 +- .../backend/api/test/test_proxy.py | 57 ++++++ .../ai_observability/backend/llm/errors.py | 19 ++ .../backend/llm/providers/_diagnostics.py | 3 +- .../backend/llm/providers/azure_openai.py | 8 +- .../llm/providers/openai_compatible.py | 25 ++- .../llm/providers/test/test_azure_openai.py | 8 + .../providers/test/test_openai_compatible.py | 183 ++++++++++-------- .../backend/llm/system_one.py | 18 +- 20 files changed, 426 insertions(+), 161 deletions(-) create mode 100644 ee/hogai/utils/test/test_asgi.py diff --git a/docs/internal/ai-observability-judge-inputs.md b/docs/internal/ai-observability-judge-inputs.md index cf04cbb6c0af..7d3a14659e2d 100644 --- a/docs/internal/ai-observability-judge-inputs.md +++ b/docs/internal/ai-observability-judge-inputs.md @@ -47,11 +47,11 @@ Key validation and model listing use a 10-second total deadline. Responses, including errors and streamed completions, are limited to 1 MiB. The endpoint must return uncompressed responses; compressed responses are rejected before decoding. Expired requests, rejected responses, and streams closed by the caller close their underlying connection. -Stream cleanup also closes the connection when a playground disconnect finalizes a generator on an active event-loop thread. The OpenAI SDK does not retry custom-provider requests. Online evaluations use their existing Temporal retry policy for transient failures, and worker cancellation propagates to Temporal. Models without native structured-output support retain the JSON fallback, which can make one additional bounded request. -Oversized or compressed completion responses skip the evaluation as an unreadable response without disabling the connection. +Oversized or compressed completion responses skip the evaluation as a rejected request without disabling the connection. +The evaluation records the response limit and how to configure the endpoint. These connection and response limits also apply when using the same provider in the playground. ## System One judges diff --git a/ee/hogai/utils/asgi.py b/ee/hogai/utils/asgi.py index a613ac8bb102..98e7b62ed62d 100644 --- a/ee/hogai/utils/asgi.py +++ b/ee/hogai/utils/asgi.py @@ -1,4 +1,5 @@ -from collections.abc import AsyncIterator, Callable, Iterable, Iterator +import threading +from collections.abc import AsyncIterator, Iterable, Iterator from typing import TypeVar from asgiref.sync import sync_to_async @@ -9,18 +10,37 @@ class SyncIterableToAsync(AsyncIterator[T]): def __init__(self, iterable: Iterable[T]) -> None: self._iterable: Iterable[T] = iterable - # async versions of the `next` and `iter` functions - self.next_async: Callable = sync_to_async(self.next, thread_sensitive=False) - self.iter_async: Callable = sync_to_async(iter, thread_sensitive=False) self.sync_iterator: Iterator[T] | None = None + self._lock = threading.Lock() + self._closed = False def __aiter__(self) -> AsyncIterator[T]: return self async def __anext__(self) -> T: - if self.sync_iterator is None: - self.sync_iterator = await self.iter_async(self._iterable) - return await self.next_async(self.sync_iterator) + return await sync_to_async(self._next, thread_sensitive=False)() + + def _next(self) -> T: + with self._lock: + if self._closed: + raise StopAsyncIteration + if self.sync_iterator is None: + self.sync_iterator = iter(self._iterable) + return self.next(self.sync_iterator) + + def _close(self) -> None: + # Cancellation stops the await but not the worker, so closing must wait for an in-flight next(). + with self._lock: + if self._closed: + return + self._closed = True + iterator = self.sync_iterator if self.sync_iterator is not None else self._iterable + close = getattr(iterator, "close", None) + if close is not None: + close() + + async def aclose(self) -> None: + await sync_to_async(self._close, thread_sensitive=False)() @staticmethod def next(it: Iterator[T]) -> T: diff --git a/ee/hogai/utils/test/test_asgi.py b/ee/hogai/utils/test/test_asgi.py new file mode 100644 index 000000000000..09dd1e819e05 --- /dev/null +++ b/ee/hogai/utils/test/test_asgi.py @@ -0,0 +1,54 @@ +import asyncio +import threading +from collections.abc import Iterator + +import pytest + +from parameterized import parameterized + +from ee.hogai.utils.asgi import SyncIterableToAsync + + +class TestSyncIterableToAsync: + @parameterized.expand([("idle", False), ("reading", True)]) + async def test_closes_cancelled_stream_in_worker(self, _name: str, reading: bool) -> None: + loop = asyncio.get_running_loop() + read_started = asyncio.Event() + close_started = asyncio.Event() + release_read = threading.Event() + release_close = threading.Event() + closed_on: list[int] = [] + + def generate() -> Iterator[int]: + try: + yield 1 + loop.call_soon_threadsafe(read_started.set) + assert release_read.wait(5) + yield 2 + finally: + loop.call_soon_threadsafe(close_started.set) + assert release_close.wait(5) + closed_on.append(threading.get_ident()) + + stream = SyncIterableToAsync(generate()) + assert await anext(stream) == 1 + if reading: + pending = asyncio.create_task(anext(stream)) + await asyncio.wait_for(read_started.wait(), 5) + pending.cancel() + with pytest.raises(asyncio.CancelledError): + await pending + + cleanup = asyncio.create_task(stream.aclose()) + try: + release_read.set() + await asyncio.wait_for(close_started.wait(), 5) + finally: + release_close.set() + await asyncio.wait_for(cleanup, 5) + + assert len(closed_on) == 1 + assert closed_on[0] != threading.get_ident() + await stream.aclose() + with pytest.raises(StopAsyncIteration): + await anext(stream) diff --git a/posthog/api/streaming.py b/posthog/api/streaming.py index c95411be64aa..43302d09d3d1 100644 --- a/posthog/api/streaming.py +++ b/posthog/api/streaming.py @@ -171,8 +171,10 @@ async def _instrumented_aiter( _record_stream_open(endpoint) started_at = time.monotonic() outcome = "completed" + iterator: AsyncIterator[bytes | str] | None = None try: - async for chunk in stream: + iterator = aiter(stream) + async for chunk in iterator: yield chunk except (GeneratorExit, asyncio.CancelledError): outcome = "client_disconnect" @@ -181,8 +183,13 @@ async def _instrumented_aiter( outcome = "error" raise finally: - _record_stream_close(endpoint, outcome, started_at) - reservation.release() + try: + close = getattr(iterator, "aclose", None) + if close is not None: + await close() + finally: + _record_stream_close(endpoint, outcome, started_at) + reservation.release() def _instrumented_iter( diff --git a/posthog/api/test/test_streaming.py b/posthog/api/test/test_streaming.py index a2e5a7edaa27..c43d62466702 100644 --- a/posthog/api/test/test_streaming.py +++ b/posthog/api/test/test_streaming.py @@ -144,9 +144,15 @@ async def agen(): assert _closed_total("test_async_complete", "completed") == 1.0 async def test_async_stream_early_close_counts_client_disconnect(self): + closed = False + async def endless(): - while True: - yield b": ping\n\n" + nonlocal closed + try: + while True: + yield b": ping\n\n" + finally: + closed = True # An abandoned async stream is aclosed by the event loop's async # generator finalizer, not by response.close() (Django's resource @@ -161,6 +167,7 @@ async def endless(): await inner.__anext__() assert _open_connections("test_async_disconnect") == 1.0 await inner.aclose() + assert closed assert _open_connections("test_async_disconnect") == 0.0 assert _closed_total("test_async_disconnect", "client_disconnect") == 1.0 assert streaming._active_stream_count == baseline diff --git a/posthog/security/bounded_httpx.py b/posthog/security/bounded_httpx.py index fbb1b31e970f..804235bb4dd9 100644 --- a/posthog/security/bounded_httpx.py +++ b/posthog/security/bounded_httpx.py @@ -1,6 +1,5 @@ import asyncio from collections.abc import Awaitable, Callable, Iterator -from concurrent.futures import ThreadPoolExecutor from typing import Any from django.utils.asyncio import async_unsafe @@ -80,25 +79,15 @@ async def _close(self) -> None: finally: await self._transport.aclose() - def _close_in_runner(self) -> None: - try: - self._runner.run(self._close()) - finally: - self._runner.close() - self._owner.streams.discard(self) - def close(self) -> None: if self._closed: return self._closed = True try: - asyncio.get_running_loop() - except RuntimeError: - self._close_in_runner() - else: - # ASGI disconnects can finalize generators on an active event-loop thread, which cannot run another loop. - with ThreadPoolExecutor(max_workers=1) as executor: - executor.submit(self._close_in_runner).result() + self._runner.run(self._close()) + finally: + self._runner.close() + self._owner.streams.discard(self) class BoundedHTTPTransport(httpx.BaseTransport): diff --git a/posthog/temporal/ai_observability/evaluation_llm_judge.py b/posthog/temporal/ai_observability/evaluation_llm_judge.py index 0d14df029fc8..92be5cd303b5 100644 --- a/posthog/temporal/ai_observability/evaluation_llm_judge.py +++ b/posthog/temporal/ai_observability/evaluation_llm_judge.py @@ -55,6 +55,7 @@ ModelPermissionError, OutputTokenLimitError, ProviderConnectionError, + ProviderRequestRejectedError, QuotaExceededError, RateLimitError, StructuredOutputParseError, @@ -65,7 +66,6 @@ SystemOneClient, SystemOneEndpointBlockedError, SystemOneRateLimitError, - SystemOneRequestRejectedError, system_one_evaluations_enabled, ) from products.ai_observability.backend.llm.types import CompletionResponse @@ -735,7 +735,7 @@ def call_llm_judge( key_id=key_id, is_byok=is_byok, ) - except SystemOneRequestRejectedError as e: + except ProviderRequestRejectedError as e: increment_user_errors("request_rejected", provider=provider) return build_skipped_evaluation_result( output_type=output_type, diff --git a/posthog/temporal/ai_observability/run_tagger.py b/posthog/temporal/ai_observability/run_tagger.py index b5ffdeb2980b..9543590e7f5b 100644 --- a/posthog/temporal/ai_observability/run_tagger.py +++ b/posthog/temporal/ai_observability/run_tagger.py @@ -27,6 +27,7 @@ ModelNotFoundError, ModelPermissionError, OutputTokenLimitError, + ProviderRequestRejectedError, QuotaExceededError, RateLimitError, StructuredOutputParseError, @@ -49,6 +50,7 @@ TAGGER_DISABLED_ERROR_TYPE = "tagger_disabled" TAGGER_PARSE_ERROR_TYPE = "tagger_parse_error" +TAGGER_REQUEST_REJECTED_ERROR_TYPE = "tagger_request_rejected" # model_resolution is shared with evaluations, so the tagger types its skip reasons on the way out. MODEL_RESOLUTION_SKIP_ERROR_TYPES = { "provider_key_required": "tagger_provider_key_required", @@ -57,7 +59,12 @@ } # RunTaggerWorkflow turns these into a skipped result, so they must stay out of error tracking. SKIPPED_RESULT_ERROR_TYPES = frozenset( - {TAGGER_DISABLED_ERROR_TYPE, TAGGER_PARSE_ERROR_TYPE, *MODEL_RESOLUTION_SKIP_ERROR_TYPES.values()} + { + TAGGER_DISABLED_ERROR_TYPE, + TAGGER_PARSE_ERROR_TYPE, + TAGGER_REQUEST_REJECTED_ERROR_TYPE, + *MODEL_RESOLUTION_SKIP_ERROR_TYPES.values(), + } ) @@ -326,6 +333,13 @@ def execute_tagger_activity(inputs: ExecuteTaggerInputs) -> dict[str, Any]: f"Model '{model}' not found.", non_retryable=True, ) + except ProviderRequestRejectedError as e: + raise ApplicationError( + str(e), + {"error_type": "request_rejected"}, + type=TAGGER_REQUEST_REJECTED_ERROR_TYPE, + non_retryable=True, + ) from e except (OutputTokenLimitError, StructuredOutputParseError) as e: # A reply cut off at the output limit reaches the tagger as unusable output, same as a # malformed one, so both take the parse path. diff --git a/posthog/temporal/ai_observability/test_run_evaluation.py b/posthog/temporal/ai_observability/test_run_evaluation.py index 568690cd559b..f80ad8c2d773 100644 --- a/posthog/temporal/ai_observability/test_run_evaluation.py +++ b/posthog/temporal/ai_observability/test_run_evaluation.py @@ -606,17 +606,25 @@ def test_system_one_restricted_connection_does_not_send_evaluation_data(base_url @pytest.mark.parametrize( - "status, expected_skip_reason", - [(301, "endpoint_blocked"), (400, "request_rejected"), (422, "request_rejected")], + "provider,status,encoding,expected_skip_reason", + [ + ("system_one", 301, "identity", "endpoint_blocked"), + ("system_one", 400, "identity", "request_rejected"), + ("system_one", 422, "identity", "request_rejected"), + ("system_one", 200, "gzip", "request_rejected"), + ("openai_compatible", 200, "gzip", "request_rejected"), + ], ) -def test_system_one_rejections_distinguish_blocked_endpoints_from_bad_inputs( - status: int, expected_skip_reason: str +def test_provider_rejections_distinguish_blocked_endpoints_from_bad_inputs( + provider: str, status: int, encoding: str, expected_skip_reason: str ) -> None: key = MagicMock( - provider="system_one", + provider=provider, encrypted_config={"api_key": "example-token", "base_url": "https://decisions.example.com/v1"}, ) - response = httpx.Response(status, stream=httpx.ByteStream(b"Invalid request")) + response = httpx.Response( + status, stream=httpx.ByteStream(b"Invalid request"), headers={"Content-Encoding": encoding} + ) with ( patch("posthog.security.url_validation.resolve_host_ips", return_value={ip_address("8.8.8.8")}), patch("posthog.temporal.ai_observability.evaluation_llm_judge.model_spec") as spec, @@ -626,7 +634,7 @@ def test_system_one_rejections_distinguish_blocked_endpoints_from_bad_inputs( patch("httpx.AsyncHTTPTransport.handle_async_request", return_value=response), ): spec.return_value.resolve.return_value = MagicMock( - provider="system_one", model="example-judge-v1", provider_key=key, is_byok=True + provider=provider, model="example-judge-v1", provider_key=key, is_byok=True ) result = call_llm_judge( evaluation={"id": "test-evaluation", "team_id": 1, "evaluation_config": {"prompt": "Polite?"}}, @@ -645,6 +653,9 @@ def test_system_one_rejections_distinguish_blocked_endpoints_from_bad_inputs( assert "model" not in result assert "provider" not in result + if encoding == "gzip": + assert "uncompressed responses no larger than 1 MiB" in result["reasoning"] + def test_system_one_rate_limit_retries_without_disabling_the_evaluation() -> None: key = MagicMock( diff --git a/posthog/temporal/ai_observability/test_run_tagger.py b/posthog/temporal/ai_observability/test_run_tagger.py index 8141c35d81ea..066b922ec7e3 100644 --- a/posthog/temporal/ai_observability/test_run_tagger.py +++ b/posthog/temporal/ai_observability/test_run_tagger.py @@ -7,6 +7,7 @@ import pytest from unittest.mock import MagicMock, patch +import httpx from temporalio.exceptions import ApplicationError from posthog.api.capture import CaptureInternalError @@ -14,7 +15,11 @@ from posthog.sync import database_sync_to_async from posthog.temporal.common.posthog_client import EXPECTED_CONTROL_FLOW_ERROR_TYPES, is_expected_activity_failure -from products.ai_observability.backend.llm.errors import OutputTokenLimitError, StructuredOutputParseError +from products.ai_observability.backend.llm.errors import ( + OutputTokenLimitError, + ProviderRequestRejectedError, + StructuredOutputParseError, +) from products.ai_observability.backend.models.provider_keys import LLMProviderKey from products.ai_observability.backend.models.taggers import Tagger @@ -811,14 +816,17 @@ def test_skipped_result_types_are_expected_control_flow(self) -> None: assert SKIPPED_RESULT_ERROR_TYPES <= EXPECTED_CONTROL_FLOW_ERROR_TYPES @pytest.mark.parametrize( - "llm_error", + "llm_error,error_type", [ - OutputTokenLimitError("The model reached its output token limit."), - StructuredOutputParseError("The reply did not match the schema."), + (OutputTokenLimitError("The model reached its output token limit."), "parse_error"), + (StructuredOutputParseError("The reply did not match the schema."), "parse_error"), + (ProviderRequestRejectedError("The response exceeds the limit."), "request_rejected"), ], ) @pytest.mark.django_db(transaction=True) - def test_unusable_reply_is_skipped_not_captured(self, setup_data: SetupData, llm_error: Exception) -> None: + def test_unusable_reply_is_skipped_not_captured( + self, setup_data: SetupData, llm_error: Exception, error_type: str + ) -> None: team = setup_data["team"] tagger = { "id": str(setup_data["tagger"].id), @@ -838,7 +846,56 @@ def test_unusable_reply_is_skipped_not_captured(self, setup_data: SetupData, llm with pytest.raises(ApplicationError) as exc_info: execute_tagger_activity(ExecuteTaggerInputs(tagger=tagger, event_data=create_mock_event_data(team.id))) - assert exc_info.value.details[0]["error_type"] == "parse_error" - assert exc_info.value.type == "tagger_parse_error" + assert exc_info.value.details[0]["error_type"] == error_type + assert exc_info.value.type == f"tagger_{error_type}" assert is_expected_activity_failure(exc_info.value) mock_capture_exception.assert_not_called() + + +def test_custom_provider_tagger_uses_bounded_completion() -> None: + key = LLMProviderKey( + id=uuid.uuid4(), + provider="openai_compatible", + state=LLMProviderKey.State.OK, + encrypted_config={"api_key": "test-key", "base_url": "https://8.8.8.8/v1"}, + ) + payload = json.dumps( + { + "id": "fixture", + "object": "chat.completion", + "created": 0, + "model": "some-model", + "choices": [ + { + "index": 0, + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": json.dumps({"tags": ["billing"], "reasoning": "Billing question"}), + }, + } + ], + } + ).encode() + with ( + patch.object(key, "save"), + patch("posthog.temporal.ai_observability.model_resolution.EvaluationConfig") as configs, + patch( + "httpx.AsyncHTTPTransport.handle_async_request", + return_value=httpx.Response(200, stream=httpx.ByteStream(payload)), + ), + ): + configs.objects.get_or_create.return_value = (MagicMock(active_provider_key=key), False) + result = execute_tagger_activity( + ExecuteTaggerInputs( + tagger={ + "id": "test-tagger", + "team_id": 1, + "tagger_config": make_tagger_config(), + "model_configuration": {"provider": "openai_compatible", "model": "some-model"}, + }, + event_data=create_mock_event_data(1), + ) + ) + assert result["tags"] == ["billing"] + assert result["reasoning"] == "Billing question" diff --git a/posthog/temporal/common/posthog_client.py b/posthog/temporal/common/posthog_client.py index 034e845f964f..b2c2b5c420d0 100644 --- a/posthog/temporal/common/posthog_client.py +++ b/posthog/temporal/common/posthog_client.py @@ -48,6 +48,7 @@ "SandboxControlPlaneUnavailableError", "tagger_disabled", "tagger_parse_error", + "tagger_request_rejected", "tagger_provider_key_required", "tagger_key_invalid", "tagger_no_default_model", diff --git a/products/ai_observability/backend/api/proxy.py b/products/ai_observability/backend/api/proxy.py index 30333278d936..effb4d136fb0 100644 --- a/products/ai_observability/backend/api/proxy.py +++ b/products/ai_observability/backend/api/proxy.py @@ -9,6 +9,7 @@ import json import uuid from collections.abc import Callable, Generator +from contextlib import closing from time import perf_counter from typing import Any @@ -223,12 +224,13 @@ def _create_stream_generator( """Creates a generator that handles client disconnects and encodes responses""" started = perf_counter() try: - for chunk in client.stream(request_obj): - if not http_request.META.get("SERVER_NAME"): # Client disconnected - if on_error: - on_error(Exception("Client disconnected"), perf_counter() - started) - return - yield chunk.to_sse().encode() + with closing(client.stream(request_obj)) as stream: + for chunk in stream: + if not http_request.META.get("SERVER_NAME"): # Client disconnected + if on_error: + on_error(Exception("Client disconnected"), perf_counter() - started) + return + yield chunk.to_sse().encode() except ProviderConfigurationError as e: if on_error: on_error(e, perf_counter() - started) diff --git a/products/ai_observability/backend/api/test/test_proxy.py b/products/ai_observability/backend/api/test/test_proxy.py index 8fb9af94dc1b..d90c56969ae3 100644 --- a/products/ai_observability/backend/api/test/test_proxy.py +++ b/products/ai_observability/backend/api/test/test_proxy.py @@ -1,11 +1,17 @@ +import asyncio +from collections.abc import AsyncGenerator, AsyncIterator from types import SimpleNamespace from typing import cast from uuid import uuid4 +import pytest from posthog.test.base import APIBaseTest from unittest import TestCase from unittest.mock import patch +from django.http import StreamingHttpResponse + +import httpx from parameterized import parameterized from rest_framework.request import Request @@ -22,6 +28,8 @@ from products.ai_observability.backend.llm import ( PLAYGROUND_MODEL_IDS, PROVIDERS, + Client, + CompletionRequest, get_default_models, get_playground_models, ) @@ -31,6 +39,55 @@ BYOK_THROTTLES = (LLMProxyBYOKBurstRateThrottle, LLMProxyBYOKSustainedRateThrottle, LLMProxyBYOKDailyRateThrottle) +class TestPlaygroundStreamCleanup: + async def test_disconnect_closes_bounded_provider_stream(self) -> None: + class Body(httpx.AsyncByteStream): + closed = False + + async def __aiter__(self) -> AsyncIterator[bytes]: + yield b'data: {"id":"fixture","choices":[{"index":0,"delta":{"content":"hello"}}]}\n\n' + yield b"data: [DONE]\n\n" + + async def aclose(self) -> None: + self.closed = True + + body = Body() + client = Client( + provider_key=LLMProviderKey( + provider="openai_compatible", + encrypted_config={"api_key": "test-key", "base_url": "https://8.8.8.8/v1"}, + ), + capture_analytics=False, + ) + request = CompletionRequest(model="some-model", provider="openai_compatible", messages=[]) + view = LLMProxyViewSet() + stream = view._create_stream_generator(client, request, SimpleNamespace(META={"SERVER_NAME": "test"})) + with ( + patch("products.ai_observability.backend.api.proxy.SERVER_GATEWAY_INTERFACE", "ASGI"), + patch("httpx.AsyncHTTPTransport.handle_async_request", return_value=httpx.Response(200, stream=body)), + ): + response = await asyncio.to_thread(view._create_streaming_response, stream) + assert isinstance(response, StreamingHttpResponse) + iterator = cast(AsyncGenerator[bytes], aiter(response._iterator)) + first_chunk = asyncio.Event() + + async def consume() -> None: + try: + assert b"hello" in await anext(iterator) + first_chunk.set() + await asyncio.Event().wait() + finally: + await iterator.aclose() + + task = asyncio.create_task(consume()) + await asyncio.wait_for(first_chunk.wait(), 5) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + assert body.closed + + class TestLLMProxyThrottles(APIBaseTest): def setUp(self) -> None: super().setUp() diff --git a/products/ai_observability/backend/llm/errors.py b/products/ai_observability/backend/llm/errors.py index 1d2471bb14b7..4d3ca91aa815 100644 --- a/products/ai_observability/backend/llm/errors.py +++ b/products/ai_observability/backend/llm/errors.py @@ -41,6 +41,23 @@ class ProviderConnectionError(LLMError): and should not log it as an exception since it's usually resolved on the next attempt.""" +class ProviderTimeoutError(ProviderConnectionError): + def __init__(self, timeout: float) -> None: + super().__init__( + f"The endpoint did not finish within {timeout:g} seconds. Check the endpoint's response time before trying again." + ) + + +RESPONSE_LIMIT_MESSAGE = ( + "The endpoint returned a compressed or oversized response. " + "Configure it to return uncompressed responses no larger than 1 MiB." +) + + +class ProviderRequestRejectedError(LLMError): + """A non-retryable request rejection with a message safe to show to the user.""" + + class ProviderConfigurationError(LLMError): """Raised when a provider key's stored configuration cannot be used as it stands — a base URL that no longer passes the SSRF allowlist, or a required endpoint that was never set. The user @@ -167,6 +184,8 @@ def user_facing_error_message(error: Exception | None) -> str: return "This conversation is too long for the model's context window. Shorten it, then try again." if isinstance(error, OutputTokenLimitError): return "The model ran out of room before it finished its reply. Ask for a shorter answer, then try again." + if isinstance(error, (ProviderTimeoutError, ProviderRequestRejectedError)): + return str(error) if isinstance(error, ProviderConnectionError): return "Could not reach the model provider. Try again." if isinstance(error, StructuredOutputParseError): diff --git a/products/ai_observability/backend/llm/providers/_diagnostics.py b/products/ai_observability/backend/llm/providers/_diagnostics.py index 6d5a97a4d79e..b413c9aa04be 100644 --- a/products/ai_observability/backend/llm/providers/_diagnostics.py +++ b/products/ai_observability/backend/llm/providers/_diagnostics.py @@ -30,6 +30,7 @@ def tagged_http_client( *, pin: tuple[str, ResolvedIPs] | None = None, follow_redirects: bool = True, + total_timeout: float | None = None, ) -> httpx.Client: """An httpx client that tags provider responses. @@ -47,4 +48,4 @@ def tagged_http_client( if pin is None: return httpx.Client(**kwargs) url, pinned_ips = pin - return pinned_client(url, pinned_ips, **kwargs) + return pinned_client(url, pinned_ips, total_timeout=total_timeout, **kwargs) diff --git a/products/ai_observability/backend/llm/providers/azure_openai.py b/products/ai_observability/backend/llm/providers/azure_openai.py index bc5d58e04583..7e38ae147339 100644 --- a/products/ai_observability/backend/llm/providers/azure_openai.py +++ b/products/ai_observability/backend/llm/providers/azure_openai.py @@ -14,7 +14,7 @@ from posthoganalytics.ai.openai import AzureOpenAI as WrappedAzureOpenAI from products.ai_observability.backend.llm.errors import error_field_for_message -from products.ai_observability.backend.llm.providers.openai import OpenAIAdapter, OpenAIConfig +from products.ai_observability.backend.llm.providers.openai import OpenAIAdapter from products.ai_observability.backend.llm.types import AnalyticsContext logger = logging.getLogger(__name__) @@ -144,14 +144,16 @@ def _create_client( api_key=api_key, azure_endpoint=self.azure_endpoint, api_version=self.api_version, - timeout=OpenAIConfig.TIMEOUT, + timeout=self.request_timeout, + max_retries=self.max_retries, http_client=http_client, ) return openai.AzureOpenAI( api_key=api_key, azure_endpoint=self.azure_endpoint, api_version=self.api_version, - timeout=OpenAIConfig.TIMEOUT, + timeout=self.request_timeout, + max_retries=self.max_retries, http_client=http_client, ) diff --git a/products/ai_observability/backend/llm/providers/openai_compatible.py b/products/ai_observability/backend/llm/providers/openai_compatible.py index b8c424bff5a3..fbb98afba5bc 100644 --- a/products/ai_observability/backend/llm/providers/openai_compatible.py +++ b/products/ai_observability/backend/llm/providers/openai_compatible.py @@ -22,18 +22,18 @@ import openai from temporalio.exceptions import CancelledError -from posthog.security.pinned_httpx import pinned_client from posthog.security.pinned_requests import SSRFBlockedError from posthog.security.url_validation import is_url_allowed, validate_url_and_pin_ips from products.ai_observability.backend.llm.errors import ( + RESPONSE_LIMIT_MESSAGE, LLMError, ProviderConfigurationError, - ProviderConnectionError, - StructuredOutputParseError, + ProviderRequestRejectedError, + ProviderTimeoutError, error_field_for_message, ) -from products.ai_observability.backend.llm.providers._diagnostics import _tag_response +from products.ai_observability.backend.llm.providers._diagnostics import tagged_http_client from products.ai_observability.backend.llm.providers.openai import OpenAIAdapter from products.ai_observability.backend.llm.types import ( AnalyticsContext, @@ -52,10 +52,6 @@ DISALLOWED_BASE_URL_MESSAGE = "Base URL must be a public https:// URL (e.g. https://api.example.com/v1)" REDIRECT_MESSAGE = "The endpoint redirected to a different address, use that address as the base URL" -RESPONSE_LIMIT_MESSAGE = ( - "The endpoint returned a compressed or oversized response. " - "Configure it to return uncompressed responses no larger than 1 MiB." -) # Validating a key and listing models are cheap calls; don't inherit the long completion timeout. # The endpoint is user-configured, so a host that accepts the connection then stalls would otherwise @@ -73,6 +69,7 @@ ("The endpoint did not return a model list", "base_url"), ("The endpoint redirected", "base_url"), ("The endpoint returned a compressed or oversized response", "base_url"), + ("The endpoint did not finish", "base_url"), ("Could not connect to the endpoint", "base_url"), ("Invalid API key", "api_key"), ) @@ -105,13 +102,11 @@ def _pinned_http_client(base_url: str, timeout: float) -> httpx.Client: verdict = validate_url_and_pin_ips(base_url) if not verdict.allowed: raise SSRFBlockedError(verdict.reason or "URL blocked by SSRF protection") - return pinned_client( - base_url, - verdict.pinned_ips, + return tagged_http_client( + pin=(base_url, verdict.pinned_ips), timeout=timeout, total_timeout=timeout, follow_redirects=False, - event_hooks={"response": [_tag_response]}, ) @@ -154,9 +149,9 @@ def _mapped_error(self, error: Exception, model: str) -> LLMError | None: if isinstance(cause, CancelledError): raise cause if isinstance(cause, httpx.DecodingError): - return StructuredOutputParseError(RESPONSE_LIMIT_MESSAGE) + return ProviderRequestRejectedError(RESPONSE_LIMIT_MESSAGE) if isinstance(cause, httpx.TimeoutException): - return ProviderConnectionError("The endpoint exceeded the request timeout.") + return ProviderTimeoutError(self.request_timeout) return super()._mapped_error(error, model) def complete( @@ -219,6 +214,8 @@ def validate_key(api_key: str, **kwargs: Any) -> tuple[str, str | None]: except openai.APIConnectionError as error: if isinstance(error.__cause__, httpx.DecodingError): return (LLMProviderKey.State.INVALID, RESPONSE_LIMIT_MESSAGE) + if isinstance(error.__cause__, httpx.TimeoutException): + return (LLMProviderKey.State.ERROR, str(ProviderTimeoutError(VALIDATION_TIMEOUT))) return (LLMProviderKey.State.ERROR, "Could not connect to the endpoint") except Exception: logger.exception("%s key validation error", PROVIDER_DISPLAY_NAME) diff --git a/products/ai_observability/backend/llm/providers/test/test_azure_openai.py b/products/ai_observability/backend/llm/providers/test/test_azure_openai.py index 44ddb3730960..c485bfd67d9e 100644 --- a/products/ai_observability/backend/llm/providers/test/test_azure_openai.py +++ b/products/ai_observability/backend/llm/providers/test/test_azure_openai.py @@ -270,6 +270,8 @@ def test_create_client_uses_azure_config(self, mock_azure): from products.ai_observability.backend.llm.types import AnalyticsContext adapter = AzureOpenAIAdapter(azure_endpoint=MOCK_ENDPOINT, api_version="2025-01-01") + adapter.request_timeout = 12.0 + adapter.max_retries = 0 analytics = AnalyticsContext(distinct_id="test", capture=False) adapter._create_client("test-key", None, analytics) @@ -278,6 +280,8 @@ def test_create_client_uses_azure_config(self, mock_azure): assert mock_azure.call_args.kwargs["api_key"] == "test-key" assert mock_azure.call_args.kwargs["azure_endpoint"] == MOCK_ENDPOINT assert mock_azure.call_args.kwargs["api_version"] == "2025-01-01" + assert mock_azure.call_args.kwargs["timeout"] == 12.0 + assert mock_azure.call_args.kwargs["max_retries"] == 0 @patch("products.ai_observability.backend.llm.providers.azure_openai.openai.AzureOpenAI") def test_create_client_ignores_base_url(self, mock_azure): @@ -300,6 +304,8 @@ def test_create_client_uses_wrapped_client_when_analytics_enabled(self, mock_wra from products.ai_observability.backend.llm.types import AnalyticsContext adapter = AzureOpenAIAdapter(azure_endpoint=MOCK_ENDPOINT) + adapter.request_timeout = 12.0 + adapter.max_retries = 0 analytics = AnalyticsContext(distinct_id="test", capture=True) adapter._create_client("test-key", None, analytics) @@ -307,3 +313,5 @@ def test_create_client_uses_wrapped_client_when_analytics_enabled(self, mock_wra mock_wrapped.assert_called_once() assert mock_wrapped.call_args.kwargs["api_key"] == "test-key" assert mock_wrapped.call_args.kwargs["azure_endpoint"] == MOCK_ENDPOINT + assert mock_wrapped.call_args.kwargs["timeout"] == 12.0 + assert mock_wrapped.call_args.kwargs["max_retries"] == 0 diff --git a/products/ai_observability/backend/llm/providers/test/test_openai_compatible.py b/products/ai_observability/backend/llm/providers/test/test_openai_compatible.py index fb7403c3ea74..887af36507ee 100644 --- a/products/ai_observability/backend/llm/providers/test/test_openai_compatible.py +++ b/products/ai_observability/backend/llm/providers/test/test_openai_compatible.py @@ -1,8 +1,9 @@ import json -import asyncio from collections.abc import AsyncIterator, Iterator +from contextlib import contextmanager import pytest +from unittest import TestCase from unittest.mock import MagicMock, patch import httpx @@ -15,9 +16,10 @@ from posthog.security.url_validation import PinnedUrlVerdict from products.ai_observability.backend.llm.errors import ( + AuthenticationError, ProviderConfigurationError, - ProviderConnectionError, - StructuredOutputParseError, + ProviderRequestRejectedError, + ProviderTimeoutError, ) from products.ai_observability.backend.llm.providers import openai_compatible from products.ai_observability.backend.llm.providers.openai_compatible import ( @@ -30,8 +32,6 @@ ) from products.ai_observability.backend.llm.types import AnalyticsContext, CompletionRequest -from ee.hogai.utils.asgi import SyncIterableToAsync - # A public IP literal keeps the DNS-resolution check offline in tests. ALLOWED_BASE_URL = "https://8.8.8.8/v1" @@ -74,6 +74,7 @@ class TestErrorFieldForValidationMessage: ("redirect", REDIRECT_MESSAGE, "base_url"), ("connection", "Could not connect to the endpoint", "base_url"), ("response_limit", openai_compatible.RESPONSE_LIMIT_MESSAGE, "base_url"), + ("timeout", str(ProviderTimeoutError(VALIDATION_TIMEOUT)), "base_url"), ("bad_key", "Invalid API key", "api_key"), ("unattributed", "Rate limited, please try again later", None), ("none", None, None), @@ -258,39 +259,75 @@ async def aclose(self) -> None: self.close() -class TestOpenAICompatibleRequestBounds: - @pytest.mark.parametrize("operation", ["complete", "stream", "validate_key", "list_models"]) - @pytest.mark.parametrize("response_kind", ["oversized", "compressed"]) - def test_rejects_unbounded_responses(self, operation: str, response_kind: str) -> None: - body = _ResponseBody([b"x" * 8192] * 129 if response_kind == "oversized" else [b"compressed"]) - headers = {"Content-Encoding": "gzip"} if response_kind == "compressed" else {} - response = httpx.Response(200, stream=body, headers=headers) +@contextmanager +def _mock_response(body: _ResponseBody, headers: dict[str, str] | None = None) -> Iterator[None]: + response = httpx.Response(200, stream=body, headers=headers or {}) + with ( + patch("httpx.HTTPTransport.handle_request", return_value=response), + patch("httpx.AsyncHTTPTransport.handle_async_request", return_value=response), + ): + try: + yield + finally: + assert body.closed + + +@contextmanager +def _response_over_limit(response_kind: str) -> Iterator[None]: + body = _ResponseBody([b"x" * 8192] * 129 if response_kind == "oversized" else [b"compressed"]) + headers = {"Content-Encoding": "gzip"} if response_kind == "compressed" else {} + with _mock_response(body, headers): + yield + + +@contextmanager +def _dripping_response(adapter: OpenAICompatibleAdapter) -> Iterator[None]: + clock = [0.0] + body = _ResponseBody([b" "] * 4, clock) + with ( + patch.object(adapter, "request_timeout", 2.0), + patch.object(openai_compatible, "VALIDATION_TIMEOUT", 2.0), + patch("asyncio.BaseEventLoop.time", side_effect=lambda: clock[0]), + _mock_response(body, {"Content-Type": "application/json"}), + ): + yield + + +class TestOpenAICompatibleRequestBounds(TestCase): + @parameterized.expand([("oversized",), ("compressed",)]) + def test_complete_rejects_unbounded_responses(self, response_kind: str) -> None: adapter = OpenAICompatibleAdapter(base_url=ALLOWED_BASE_URL) request = _completion_request() request.response_format = _Verdict + with _response_over_limit(response_kind), pytest.raises(ProviderRequestRejectedError) as error: + adapter.complete(request, "test-key", AnalyticsContext(capture=False)) + assert str(error.value) == openai_compatible.RESPONSE_LIMIT_MESSAGE - with ( - patch("httpx.HTTPTransport.handle_request", return_value=response), - patch("httpx.AsyncHTTPTransport.handle_async_request", return_value=response), - patch("openai._base_client.time.sleep"), - ): - if operation == "complete": - with pytest.raises(StructuredOutputParseError, match="compressed or oversized"): - adapter.complete(request, "test-key", AnalyticsContext(capture=False)) - elif operation == "stream": - chunks = list(adapter.stream(_completion_request(), "test-key", AnalyticsContext(capture=False))) - assert [chunk.type for chunk in chunks] == ["error"] - elif operation == "validate_key": - state, message = adapter.validate_key("test-key", base_url=ALLOWED_BASE_URL) - assert state == "invalid" - assert message is not None and "compressed or oversized" in message - else: - assert adapter.list_models("test-key", base_url=ALLOWED_BASE_URL) == [] + @parameterized.expand([("oversized",), ("compressed",)]) + def test_stream_reports_response_limit(self, response_kind: str) -> None: + adapter = OpenAICompatibleAdapter(base_url=ALLOWED_BASE_URL) + with _response_over_limit(response_kind): + chunks = list(adapter.stream(_completion_request(), "test-key", AnalyticsContext(capture=False))) + assert [(chunk.type, chunk.data) for chunk in chunks] == [ + ("error", {"error": openai_compatible.RESPONSE_LIMIT_MESSAGE}) + ] - assert body.closed + @parameterized.expand([("oversized",), ("compressed",)]) + def test_validate_key_reports_response_limit(self, response_kind: str) -> None: + with _response_over_limit(response_kind): + result = OpenAICompatibleAdapter.validate_key("test-key", base_url=ALLOWED_BASE_URL) + assert result == ("invalid", openai_compatible.RESPONSE_LIMIT_MESSAGE) + + @parameterized.expand([("oversized",), ("compressed",)]) + def test_list_models_logs_response_limit(self, response_kind: str) -> None: + with _response_over_limit(response_kind), self.assertLogs(openai_compatible.logger) as logs: + assert OpenAICompatibleAdapter.list_models("test-key", base_url=ALLOWED_BASE_URL) == [] + assert logs.records[0].exc_info is not None + error = logs.records[0].exc_info[1] + assert isinstance(error, openai.APIConnectionError) + assert isinstance(error.__cause__, httpx.DecodingError) - @pytest.mark.parametrize("operation", ["complete", "stream"]) - @pytest.mark.parametrize("capture", [False, True]) + @parameterized.expand([("complete", False), ("complete", True), ("stream", False), ("stream", True)]) def test_preserves_cancellation_without_retrying(self, operation: str, capture: bool) -> None: adapter = OpenAICompatibleAdapter(base_url=ALLOWED_BASE_URL) request = _completion_request() @@ -298,7 +335,6 @@ def test_preserves_cancellation_without_retrying(self, operation: str, capture: with ( patch("httpx.HTTPTransport.handle_request", side_effect=CancelledError) as sync_send, patch("httpx.AsyncHTTPTransport.handle_async_request", side_effect=CancelledError) as async_send, - patch("openai._base_client.time.sleep"), patch("posthoganalytics.default_client", MagicMock()), pytest.raises(CancelledError), ): @@ -306,47 +342,43 @@ def test_preserves_cancellation_without_retrying(self, operation: str, capture: adapter.complete(request, "test-key", AnalyticsContext(capture=capture)) else: list(adapter.stream(request, "test-key", AnalyticsContext(capture=capture))) - assert sync_send.call_count + async_send.call_count == 1 - @pytest.mark.parametrize("operation", ["complete", "stream", "validate_key", "list_models"]) - def test_total_deadline_stops_dripping_response(self, operation: str) -> None: - payload = json.dumps( - { - "id": "fixture", - "object": "chat.completion", - "created": 0, - "model": "some-model", - "choices": [{"index": 0, "finish_reason": "stop", "message": {"role": "assistant", "content": "ok"}}], - } - ).encode() - clock = [0.0] - body = _ResponseBody([b" "] * 4 + [payload], clock) - response = httpx.Response(200, stream=body, headers={"Content-Type": "application/json"}) + def test_complete_stops_at_total_deadline(self) -> None: adapter = OpenAICompatibleAdapter(base_url=ALLOWED_BASE_URL) + with _dripping_response(adapter), pytest.raises(ProviderTimeoutError, match="within 2 seconds"): + adapter.complete(_completion_request(), "test-key", AnalyticsContext(capture=False)) + def test_stream_reports_total_deadline(self) -> None: + adapter = OpenAICompatibleAdapter(base_url=ALLOWED_BASE_URL) + with _dripping_response(adapter): + chunks = list(adapter.stream(_completion_request(), "test-key", AnalyticsContext(capture=False))) + assert [(chunk.type, chunk.data) for chunk in chunks] == [("error", {"error": str(ProviderTimeoutError(2))})] + + def test_validate_key_reports_total_deadline(self) -> None: + adapter = OpenAICompatibleAdapter(base_url=ALLOWED_BASE_URL) + with _dripping_response(adapter): + result = adapter.validate_key("test-key", base_url=ALLOWED_BASE_URL) + assert result == ("error", str(ProviderTimeoutError(2))) + + def test_list_models_logs_total_deadline(self) -> None: + adapter = OpenAICompatibleAdapter(base_url=ALLOWED_BASE_URL) + with _dripping_response(adapter), self.assertLogs(openai_compatible.logger) as logs: + assert adapter.list_models("test-key", base_url=ALLOWED_BASE_URL) == [] + assert logs.records[0].exc_info is not None + error = logs.records[0].exc_info[1] + assert isinstance(error, openai.APIConnectionError) + assert isinstance(error.__cause__, httpx.TimeoutException) + + def test_complete_preserves_authentication_errors(self) -> None: + response = httpx.Response(401, stream=httpx.ByteStream(b'{"error":{"message":"Invalid API key"}}')) with ( - patch.object(adapter, "request_timeout", 2.0), - patch.object(openai_compatible, "VALIDATION_TIMEOUT", 2.0), - patch("asyncio.BaseEventLoop.time", side_effect=lambda: clock[0]), - patch("httpx.HTTPTransport.handle_request", return_value=response) as sync_send, - patch("httpx.AsyncHTTPTransport.handle_async_request", return_value=response) as async_send, - patch("openai._base_client.time.sleep"), + patch("httpx.AsyncHTTPTransport.handle_async_request", return_value=response), + pytest.raises(AuthenticationError), ): - if operation == "complete": - with pytest.raises(ProviderConnectionError): - adapter.complete(_completion_request(), "test-key", AnalyticsContext(capture=False)) - elif operation == "stream": - chunks = list(adapter.stream(_completion_request(), "test-key", AnalyticsContext(capture=False))) - assert [chunk.type for chunk in chunks] == ["error"] - elif operation == "validate_key": - state, _ = adapter.validate_key("test-key", base_url=ALLOWED_BASE_URL) - assert state == "error" - else: - assert adapter.list_models("test-key", base_url=ALLOWED_BASE_URL) == [] - - assert sync_send.call_count + async_send.call_count == 1 - assert body.closed + OpenAICompatibleAdapter(base_url=ALLOWED_BASE_URL).complete( + _completion_request(), "test-key", AnalyticsContext(capture=False) + ) def test_structured_output_falls_back_without_sdk_retries(self) -> None: fallback_body = _ResponseBody( @@ -388,8 +420,7 @@ def test_structured_output_falls_back_without_sdk_retries(self) -> None: assert send.call_count == 2 assert fallback_body.closed - @pytest.mark.parametrize("close_in_event_loop", [False, True]) - def test_closing_stream_closes_the_connection(self, close_in_event_loop: bool) -> None: + def test_closing_stream_closes_the_connection(self) -> None: payload = json.dumps( { "id": "fixture", @@ -408,15 +439,7 @@ def test_closing_stream_closes_the_connection(self, close_in_event_loop: bool) - patch("httpx.AsyncHTTPTransport.handle_async_request", return_value=response), ): stream = adapter.stream(_completion_request(), "test-key", AnalyticsContext(capture=False)) - if close_in_event_loop: - - async def consume_then_close() -> None: - assert (await anext(SyncIterableToAsync(stream))).data == {"text": "hello"} - stream.close() - - asyncio.run(consume_then_close()) - else: - assert next(stream).data == {"text": "hello"} - stream.close() + assert next(stream).data == {"text": "hello"} + stream.close() assert body.closed diff --git a/products/ai_observability/backend/llm/system_one.py b/products/ai_observability/backend/llm/system_one.py index bfc0a05e1fd6..74f80f7db869 100644 --- a/products/ai_observability/backend/llm/system_one.py +++ b/products/ai_observability/backend/llm/system_one.py @@ -19,22 +19,23 @@ ) from posthog.models import Team from posthog.ph_client import get_feature_flag_or_none -from posthog.security.pinned_httpx import pinned_client from posthog.security.pinned_requests import SSRFBlockedError from posthog.security.url_validation import has_authority_bypass_chars, validate_url_and_pin_ips from products.ai_observability.backend.llm.errors import ( + RESPONSE_LIMIT_MESSAGE, AuthenticationError, ContextWindowExceededError, LLMError, ModelNotFoundError, ModelPermissionError, ProviderConnectionError, + ProviderRequestRejectedError, RateLimitError, StructuredOutputParseError, is_context_window_error_message, ) -from products.ai_observability.backend.llm.providers._diagnostics import _tag_response +from products.ai_observability.backend.llm.providers._diagnostics import tagged_http_client def system_one_evaluations_enabled(team_id: int, *, base_url: str) -> bool: @@ -62,7 +63,7 @@ def system_one_evaluations_enabled(team_id: int, *, base_url: str) -> bool: ) -class SystemOneRequestRejectedError(LLMError): +class SystemOneRequestRejectedError(ProviderRequestRejectedError): pass @@ -126,13 +127,11 @@ def evaluate( verdict = validate_url_and_pin_ips(base_url) if not verdict.allowed: raise SSRFBlockedError(verdict.reason) - with pinned_client( - base_url, - verdict.pinned_ips, + with tagged_http_client( + pin=(base_url, verdict.pinned_ips), timeout=timeout, total_timeout=timeout, follow_redirects=False, - event_hooks={"response": [_tag_response]}, ) as client: response = client.post( f"{base_url}/systemone", @@ -142,10 +141,7 @@ def evaluate( except SSRFBlockedError as error: raise SystemOneEndpointBlockedError("This endpoint is not allowed. Use a public HTTPS endpoint.") from error except httpx.DecodingError as error: - raise SystemOneRequestRejectedError( - "The endpoint returned a compressed or oversized response. " - "Configure it to return uncompressed responses no larger than 1 MiB." - ) from error + raise SystemOneRequestRejectedError(RESPONSE_LIMIT_MESSAGE) from error except httpx.RequestError as error: raise ProviderConnectionError("Could not reach the System One endpoint. Try again.") from error From 3965e303433ddab4d32e86a15162f742eba68ed1 Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Fri, 2 Oct 2026 15:37:16 +0200 Subject: [PATCH 17/48] fix(sql-editor): address bi worksheet review feedback --- .../data-warehouse/editor/bi/BIEditor.tsx | 22 ++++- .../data-warehouse/editor/bi/biEditorLogic.ts | 97 ++++++++++++++++--- .../editor/bi/biEditorTypes.test.ts | 32 ++++++ .../data-warehouse/editor/bi/biEditorTypes.ts | 6 +- .../editor/bi/components/BIFieldPill.tsx | 19 ++-- .../editor/bi/components/BIFilterPill.tsx | 10 +- .../editor/bi/components/BIFiltersCard.tsx | 16 ++- .../editor/bi/components/BIPill.tsx | 9 +- .../bi/components/BIShelfDropTarget.tsx | 1 + .../editor/bi/components/BIShowMe.tsx | 6 +- .../editor/sqlEditorLogic.test.ts | 93 ++++++++++++++++++ 11 files changed, 264 insertions(+), 47 deletions(-) diff --git a/frontend/src/scenes/data-warehouse/editor/bi/BIEditor.tsx b/frontend/src/scenes/data-warehouse/editor/bi/BIEditor.tsx index 3c7a15c063c9..77610fc06a81 100644 --- a/frontend/src/scenes/data-warehouse/editor/bi/BIEditor.tsx +++ b/frontend/src/scenes/data-warehouse/editor/bi/BIEditor.tsx @@ -1,4 +1,4 @@ -import { BindLogic, useValues } from 'kea' +import { BindLogic, useActions, useValues } from 'kea' import type { ReactNode } from 'react' import { Resizer } from 'lib/components/Resizer/Resizer' @@ -6,6 +6,7 @@ import { IconTableChart } from 'lib/lemon-ui/icons' import { editorSizingLogic } from '../editorSizingLogic' import { biEditorLogic } from './biEditorLogic' +import { BI_SHELF_PILL_DRAG_MIME_TYPE, parseBIShelfPillDragData } from './biEditorTypes' import { BIDataPane } from './components/BIDataPane' import { BIFieldPill } from './components/BIFieldPill' import { BIFiltersCard } from './components/BIFiltersCard' @@ -20,11 +21,28 @@ import { BIToolbar } from './components/BIToolbar' */ export function BIEditor({ tabId, children }: { tabId: string; children: ReactNode }): JSX.Element { const { config, showMeOpen } = useValues(biEditorLogic({ tabId })) + const { removeFieldFromShelf, setActiveDropShelf } = useActions(biEditorLogic({ tabId })) const { biSidePaneWidth, biEditorResizerProps } = useValues(editorSizingLogic) return ( -
+
{ + if (event.dataTransfer.types.includes(BI_SHELF_PILL_DRAG_MIME_TYPE)) { + event.preventDefault() + } + }} + onDrop={(event) => { + const pill = parseBIShelfPillDragData(event.dataTransfer.getData(BI_SHELF_PILL_DRAG_MIME_TYPE)) + if (pill) { + event.preventDefault() + removeFieldFromShelf(pill.shelf, pill.index) + } + setActiveDropShelf(null) + }} + onDragEnd={() => setActiveDropShelf(null)} + >
> @@ -237,6 +239,7 @@ export interface biEditorLogicValues { editorView: BIEditorView filteredDataPaneFields: BIDataPaneFields generatedQuery: BIQueryBuildResult | null + hoveredChartType: ChartDisplayType | null selectableDataSources: BIDataSource[] showMeOpen: boolean sortOptions: BISortOption[] @@ -247,6 +250,21 @@ export interface biEditorLogicActions { hydrateTableFields: (tableNames: string[]) => { tableNames: string[] } // databaseTableListLogic + loadDatabaseSuccess: ( + database: Required | null, + payload?: + | { + force?: boolean + shallow?: boolean + } + | undefined + ) => { + database: Required | null + payload?: { + force?: boolean + shallow?: boolean + } + } // databaseTableListLogic setSourceQuery: (sourceQuery: import('~/queries/schema/schema-general').DataVisualizationNode) => { sourceQuery: import('~/queries/schema/schema-general').DataVisualizationNode } // sqlEditorLogic @@ -302,11 +320,15 @@ export interface biEditorLogicActions { runAfterChange: () => { value: true } - setActiveDropShelf: (shelf: BIShelf) => { - shelf: BIShelf + setActiveDropShelf: (shelf: BIShelf | null) => { + shelf: BIShelf | null } - setActiveExpressionEditorId: (fieldId: string | null) => { + setActiveExpressionEditorId: ( + fieldId: string | null, + target?: 'aggregation' | 'field' + ) => { fieldId: string | null + target: 'aggregation' | 'field' } setAutoUpdate: (autoUpdate: boolean) => { autoUpdate: boolean @@ -362,6 +384,9 @@ export interface biEditorLogicActions { index: number value: string } + setHoveredChartType: (chartType: ChartDisplayType | null) => { + chartType: ChartDisplayType | null + } setLimit: (limit: BIQueryLimit) => { limit: 100 | 1000 | 10000 | 50000 } @@ -407,7 +432,11 @@ export interface biEditorLogicMeta { sortOptions: (config: BIConfig) => BISortOption[] chartFits: (config: BIConfig) => Partial> dataPaneFields: (config: BIConfig, allTables: DatabaseSchemaTable[]) => BIDataPaneFields - dataPaneFieldsLoading: (config: BIConfig, tableFieldsStatus: TableFieldsStatus) => boolean + dataPaneFieldsLoading: ( + config: BIConfig, + tableFieldsStatus: TableFieldsStatus, + databaseLoading: boolean + ) => boolean filteredDataPaneFields: (dataPaneFields: BIDataPaneFields, dataPaneSearch: string) => BIDataPaneFields } } @@ -440,7 +469,7 @@ export const biEditorLogic = kea([ sqlEditorLogic({ tabId: logicProps.tabId }), ['setSourceQuery', 'syncUrlWithQuery', 'updateTab'], databaseTableListLogic, - ['hydrateTableFields'], + ['hydrateTableFields', 'loadDatabaseSuccess'], ], })), actions({ @@ -458,11 +487,15 @@ export const biEditorLogic = kea([ swapRowsAndColumns: true, setAutoUpdate: (autoUpdate: boolean) => ({ autoUpdate }), setShowMeOpen: (showMeOpen: boolean) => ({ showMeOpen }), + setHoveredChartType: (chartType: ChartDisplayType | null) => ({ chartType }), setDataPaneSearch: (search: string) => ({ search }), runAfterChange: true, removeFieldFromShelf: (shelf: BIShelf, index: number) => ({ shelf, index }), - setActiveDropShelf: (shelf: BIShelf) => ({ shelf }), - setActiveExpressionEditorId: (fieldId: string | null) => ({ fieldId }), + setActiveDropShelf: (shelf: BIShelf | null) => ({ shelf }), + setActiveExpressionEditorId: (fieldId: string | null, target: 'field' | 'aggregation' = 'field') => ({ + fieldId, + target, + }), setChartType: (chartType: ChartDisplayType) => ({ chartType }), setDataSource: (source: BIDataSource) => ({ source }), setValueAggregation: (index: number, aggregation: BIAggregation) => ({ index, aggregation }), @@ -492,6 +525,18 @@ export const biEditorLogic = kea([ ], autoUpdate: [true, { persist: true }, { setAutoUpdate: (_, { autoUpdate }) => autoUpdate }], showMeOpen: [true, { persist: true }, { setShowMeOpen: (_, { showMeOpen }) => showMeOpen }], + hoveredChartType: [ + null as ChartDisplayType | null, + { setHoveredChartType: (_, { chartType }) => chartType, setShowMeOpen: () => null }, + ], + activeExpressionEditorTarget: [ + 'field' as 'field' | 'aggregation', + { + setActiveExpressionEditorId: (_, { target }) => target, + addBlankFieldToShelf: () => 'field', + addFieldToShelf: () => 'field', + }, + ], dataPaneSearch: [ '', { @@ -509,6 +554,9 @@ export const biEditorLogic = kea([ setActiveExpressionEditorId: (_, { fieldId }) => fieldId, resetConfig: () => null, restoreState: () => null, + removeFieldFromShelf: () => null, + moveFieldToShelf: () => null, + swapRowsAndColumns: () => null, setDataSource: () => null, setEditorView: () => null, }, @@ -636,9 +684,9 @@ export const biEditorLogic = kea([ : { dimensions: [], measures: [] }, ], dataPaneFieldsLoading: [ - (selectors) => [selectors.config, selectors.tableFieldsStatus], - (config: BIConfig, tableFieldsStatus: TableFieldsStatus): boolean => - !!config.source && tableFieldsStatus[config.source.table] === 'loading', + (selectors) => [selectors.config, selectors.tableFieldsStatus, selectors.databaseLoading], + (config: BIConfig, tableFieldsStatus: TableFieldsStatus, databaseLoading: boolean): boolean => + !!config.source && (databaseLoading || tableFieldsStatus[config.source.table] === 'loading'), ], filteredDataPaneFields: [ (selectors) => [selectors.dataPaneFields, selectors.dataPaneSearch], @@ -655,7 +703,12 @@ export const biEditorLogic = kea([ }, ], }), - listeners(({ actions, props: logicProps, values }) => ({ + listeners(({ actions, props: logicProps, values, cache }) => ({ + loadDatabaseSuccess: () => { + if (values.config.source && (values.config.source.connectionId ?? null) === values.databaseConnectionId) { + actions.hydrateTableFields([values.config.source.table]) + } + }, persistState: ({ editorView, config }) => { if (!values.activeTab) { return @@ -667,6 +720,7 @@ export const biEditorLogic = kea([ actions.syncUrlWithQuery() }, setEditorView: ({ editorView }) => { + cache.autoUpdateRevision = (cache.autoUpdateRevision ?? 0) + 1 captureBIEditorModeSelected(editorView, values.config) actions.persistState(editorView, values.config) if (editorView === BIEditorView.BI) { @@ -677,6 +731,7 @@ export const biEditorLogic = kea([ moveFieldToShelf: () => actions.runAfterChange(), swapRowsAndColumns: () => actions.runAfterChange(), setAutoUpdate: ({ autoUpdate }) => { + cache.autoUpdateRevision = (cache.autoUpdateRevision ?? 0) + 1 if (autoUpdate) { actions.runAfterChange() } @@ -684,11 +739,20 @@ export const biEditorLogic = kea([ runAfterChange: async (_, breakpoint) => { actions.persistState(values.editorView, values.config) actions.syncGeneratedQuery() - if (!values.autoUpdate || !values.generatedQuery) { + if (!values.autoUpdate || values.editorView !== BIEditorView.BI || !values.generatedQuery) { return } + const revision = cache.autoUpdateRevision // Debounced so typing a filter value or clicking through menus runs one query await breakpoint(400) + if ( + !values.autoUpdate || + values.editorView !== BIEditorView.BI || + !values.generatedQuery || + revision !== cache.autoUpdateRevision + ) { + return + } sqlEditorLogic({ tabId: logicProps.tabId }).actions.runQuery() }, addBlankFieldToShelf: () => actions.runAfterChange(), @@ -714,7 +778,11 @@ export const biEditorLogic = kea([ }) }, resetConfig: () => { + cache.autoUpdateRevision = (cache.autoUpdateRevision ?? 0) + 1 actions.persistState(values.editorView, values.config) + const dataLogic = dataNodeLogic.findMounted({ key: `data-warehouse-editor-data-node-${logicProps.tabId}` }) + dataLogic?.actions.cancelQuery() + dataLogic?.actions.clearResponse() const editorLogic = sqlEditorLogic({ tabId: logicProps.tabId }) const sourceQuery = editorLogic.values.sourceQuery editorLogic.actions.setQueryInput('') @@ -755,7 +823,10 @@ export const biEditorLogic = kea([ })), subscriptions(({ actions }) => ({ config: (config: BIConfig, oldConfig: BIConfig | undefined) => { - if (config.source && config.source.table !== oldConfig?.source?.table) { + if ( + config.source && + (!oldConfig?.source || getBIDataSourceKey(config.source) !== getBIDataSourceKey(oldConfig.source)) + ) { actions.hydrateTableFields([config.source.table]) } }, diff --git a/frontend/src/scenes/data-warehouse/editor/bi/biEditorTypes.test.ts b/frontend/src/scenes/data-warehouse/editor/bi/biEditorTypes.test.ts index f663c7862590..85ac2f71de4a 100644 --- a/frontend/src/scenes/data-warehouse/editor/bi/biEditorTypes.test.ts +++ b/frontend/src/scenes/data-warehouse/editor/bi/biEditorTypes.test.ts @@ -9,11 +9,13 @@ import { createDefaultDateFilter, defaultAggregationForField, getBIDataSourceKey, + getBIChartFit, getBIDropTarget, getBIFieldId, getBISortOptions, getBIValueSortKey, isBIFieldCompatible, + isBIMeasureField, parseBIEditorState, } from './biEditorTypes' @@ -404,6 +406,36 @@ describe('BI editor query generation', () => { const userIdField: BIField = { ...revenueField, id: 'warehouse:events:user_id', name: 'user_id', type: 'integer' } + test.each([ + ['userId', false], + ['accountId', false], + ['userID', false], + ['accountUuid', false], + ['accountUUID', false], + ['USER_ID', false], + ['UUID', false], + ['grid', true], + ['paid', true], + ])('classifies numeric field %s as a measure: %s', (name, isMeasure) => { + const field = { ...userIdField, name } + expect(isBIMeasureField(field)).toBe(isMeasure) + expect(getBIDropTarget(field, 'rows').shelf).toBe(isMeasure ? 'values' : 'rows') + }) + + test.each([0, 1, 2])('checks pivot fit with %i measures', (measureCount) => { + const pivotConfig: BIConfig = { + ...sortableConfig, + chartType: ChartDisplayType.TwoDimensionalHeatmap, + rows: [browserField], + columns: [countryField], + values: Array.from({ length: measureCount }, () => ({ field: revenueField, aggregation: 'sum' })), + } + expect(getBIChartFit(pivotConfig, ChartDisplayType.TwoDimensionalHeatmap).fits).toBe(measureCount <= 1) + expect(buildBIQuery(pivotConfig)?.node.chartSettings?.heatmap?.valueColumn).toBe( + measureCount === 0 ? 'count' : 'sum_revenue' + ) + }) + test.each([ ['a measure dropped on rows becomes a value', revenueField, 'rows', revenueField, 'values'], ['a measure dropped on columns becomes a value', revenueField, 'columns', revenueField, 'values'], diff --git a/frontend/src/scenes/data-warehouse/editor/bi/biEditorTypes.ts b/frontend/src/scenes/data-warehouse/editor/bi/biEditorTypes.ts index d15bd6a136a2..b583a0cb834f 100644 --- a/frontend/src/scenes/data-warehouse/editor/bi/biEditorTypes.ts +++ b/frontend/src/scenes/data-warehouse/editor/bi/biEditorTypes.ts @@ -204,7 +204,7 @@ export function isBIMeasureField(field: BIField): boolean { return ( isNumericBIField(field) && defaultAggregationForField(field) !== 'count' && - !IDENTIFIER_FIELD_NAME_REGEX.test(field.name) + !IDENTIFIER_FIELD_NAME_REGEX.test(field.name.replace(/([a-z0-9])([A-Z])/g, '$1_$2')) ) } @@ -315,8 +315,8 @@ export function getBIChartFit(config: BIConfig, chartType: ChartDisplayType): BI } case ChartDisplayType.TwoDimensionalHeatmap: return { - fits: rowCount >= 1 && columnCount >= 1, - requirement: '1 or more dimensions on rows and on columns', + fits: rowCount >= 1 && columnCount >= 1 && config.values.length <= 1, + requirement: '1 or more dimensions on rows and on columns, and up to 1 measure', } case ChartDisplayType.BoldNumber: case ChartDisplayType.Metric: diff --git a/frontend/src/scenes/data-warehouse/editor/bi/components/BIFieldPill.tsx b/frontend/src/scenes/data-warehouse/editor/bi/components/BIFieldPill.tsx index a8b85cb477fe..13aa9f5aa88e 100644 --- a/frontend/src/scenes/data-warehouse/editor/bi/components/BIFieldPill.tsx +++ b/frontend/src/scenes/data-warehouse/editor/bi/components/BIFieldPill.tsx @@ -1,5 +1,4 @@ import { useActions, useValues } from 'kea' -import { useState } from 'react' import { IconArrowDown, IconArrowUp } from 'lib/lemon-ui/icons' import { LemonMenu, LemonMenuItems } from 'lib/lemon-ui/LemonMenu' @@ -28,7 +27,7 @@ export function BIFieldPill({ shelf: Exclude index: number }): JSX.Element | null { - const { config, activeExpressionEditorId } = useValues(biEditorLogic) + const { config, activeExpressionEditorId, activeExpressionEditorTarget, sortOptions } = useValues(biEditorLogic) const { addFieldToShelf, moveFieldToShelf, @@ -40,7 +39,6 @@ export function BIFieldPill({ setValueAggregation, setValueCustomExpression, } = useActions(biEditorLogic) - const [editing, setEditing] = useState(null) const value = shelf === 'values' ? config.values[index] : null const field = value ? value.field : shelf === 'values' ? null : config[shelf][index] @@ -49,9 +47,14 @@ export function BIFieldPill({ } const isMeasure = shelf === 'values' - const autoOpen = activeExpressionEditorId === getBIShelfEditorKey(shelf, field.id) - const expressionTarget: ExpressionTarget | null = editing ?? (autoOpen ? 'field' : null) - const sortKey = isMeasure ? getBIValueSortKey(config, index) : `${shelf}:${field.id}` + const occurrence = isMeasure + ? config.values.slice(0, index).filter((previous) => previous.field.id === field.id).length + : 0 + const editorKey = `${getBIShelfEditorKey(shelf, field.id)}${occurrence ? `:${occurrence + 1}` : ''}` + const expressionTarget = activeExpressionEditorId === editorKey ? activeExpressionEditorTarget : null + const setEditing = (target: ExpressionTarget): void => setActiveExpressionEditorId(editorKey, target) + const candidateSortKey = isMeasure ? getBIValueSortKey(config, index) : `${shelf}:${field.id}` + const sortKey = sortOptions.some((option) => option.key === candidateSortKey) ? candidateSortKey : null const otherDimensionShelf = shelf === 'rows' ? 'columns' : 'rows' const label = value ? getBIValuePillLabel(value) : getBIFieldPillLabel(field) const incomplete = value?.aggregation === 'custom' ? !value.customExpression?.trim() : !field.expression.trim() @@ -139,8 +142,7 @@ export function BIFieldPill({ : setFieldExpression(shelf, index, nextExpression) } onClose={() => { - setEditing(null) - if (autoOpen) { + if (activeExpressionEditorId === editorKey) { setActiveExpressionEditorId(null) } }} @@ -153,7 +155,6 @@ export function BIFieldPill({ shelf={shelf} index={index} incomplete={incomplete} - onDropOutside={() => removeFieldFromShelf(shelf, index)} aria-label={`${label} options`} data-attr={`bi-editor-${shelf}-pill`} /> diff --git a/frontend/src/scenes/data-warehouse/editor/bi/components/BIFilterPill.tsx b/frontend/src/scenes/data-warehouse/editor/bi/components/BIFilterPill.tsx index 6c15b5843a7b..6853c813defd 100644 --- a/frontend/src/scenes/data-warehouse/editor/bi/components/BIFilterPill.tsx +++ b/frontend/src/scenes/data-warehouse/editor/bi/components/BIFilterPill.tsx @@ -1,5 +1,4 @@ import { useActions, useValues } from 'kea' -import { useState } from 'react' import { LemonDropdown } from '@posthog/lemon-ui' @@ -22,17 +21,15 @@ function filterSummary(filter: BIFilter): string | undefined { export function BIFilterPill({ index }: { index: number }): JSX.Element | null { const { config, activeExpressionEditorId } = useValues(biEditorLogic) - const { removeFieldFromShelf, setActiveExpressionEditorId } = useActions(biEditorLogic) - const [open, setOpen] = useState(false) + const { setActiveExpressionEditorId } = useActions(biEditorLogic) const filter = config.filters[index] if (!filter) { return null } const editorKey = getBIShelfEditorKey('filters', filter.field.id) - const visible = open || activeExpressionEditorId === editorKey + const visible = activeExpressionEditorId === editorKey const close = (): void => { - setOpen(false) if (activeExpressionEditorId === editorKey) { setActiveExpressionEditorId(null) } @@ -43,7 +40,7 @@ export function BIFilterPill({ index }: { index: number }): JSX.Element | null { return ( (nextVisible ? setOpen(true) : close())} + onVisibilityChange={(nextVisible) => (nextVisible ? setActiveExpressionEditorId(editorKey) : close())} closeOnClickInside={false} placement="right-start" overlay={} @@ -55,7 +52,6 @@ export function BIFilterPill({ index }: { index: number }): JSX.Element | null { shelf="filters" index={index} incomplete={!filter.field.expression.trim() && !filter.customExpression?.trim()} - onDropOutside={() => removeFieldFromShelf('filters', index)} className="w-full justify-between" aria-label={`${label} filter`} data-attr="bi-editor-filters-pill" diff --git a/frontend/src/scenes/data-warehouse/editor/bi/components/BIFiltersCard.tsx b/frontend/src/scenes/data-warehouse/editor/bi/components/BIFiltersCard.tsx index d86c6f6d6bc6..bbc57ee9b978 100644 --- a/frontend/src/scenes/data-warehouse/editor/bi/components/BIFiltersCard.tsx +++ b/frontend/src/scenes/data-warehouse/editor/bi/components/BIFiltersCard.tsx @@ -1,4 +1,7 @@ -import { useValues } from 'kea' +import { useActions, useValues } from 'kea' + +import { IconPlus } from '@posthog/icons' +import { LemonButton } from '@posthog/lemon-ui' import { biEditorLogic } from '../biEditorLogic' import { BIFilterPill } from './BIFilterPill' @@ -7,6 +10,7 @@ import { BIShelfDropTarget } from './BIShelfDropTarget' export function BIFiltersCard(): JSX.Element { const { config } = useValues(biEditorLogic) + const { addBlankFieldToShelf } = useActions(biEditorLogic) return ( @@ -17,6 +21,16 @@ export function BIFiltersCard(): JSX.Element { Drop fields here to filter rows )} + } + size="xsmall" + type="tertiary" + disabledReason={!config.source ? 'Select a data source first' : undefined} + onClick={() => addBlankFieldToShelf('filters')} + data-attr="bi-editor-filters-add-field" + > + Add a calculated filter + ) } diff --git a/frontend/src/scenes/data-warehouse/editor/bi/components/BIPill.tsx b/frontend/src/scenes/data-warehouse/editor/bi/components/BIPill.tsx index 9ce6318f4578..0cdb3ff8904e 100644 --- a/frontend/src/scenes/data-warehouse/editor/bi/components/BIPill.tsx +++ b/frontend/src/scenes/data-warehouse/editor/bi/components/BIPill.tsx @@ -15,13 +15,11 @@ export interface BIPillProps extends React.ButtonHTMLAttributes void } /** A draggable field on a shelf. Blue for dimensions, green for measures, like desktop BI tools. */ export const BIPill = forwardRef(function BIPill( - { kind, label, detail, shelf, index, incomplete, onDropOutside, className, ...buttonProps }, + { kind, label, detail, shelf, index, incomplete, className, ...buttonProps }, ref ) { return ( @@ -34,11 +32,6 @@ export const BIPill = forwardRef(function BIPill event.dataTransfer.effectAllowed = 'move' event.dataTransfer.setData(BI_SHELF_PILL_DRAG_MIME_TYPE, JSON.stringify(dragData)) }} - onDragEnd={(event) => { - if (event.dataTransfer.dropEffect === 'none') { - onDropOutside() - } - }} className={cn( 'inline-flex h-6 max-w-72 shrink-0 cursor-grab items-center gap-1 rounded border px-2 text-xs font-semibold', 'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-accent', diff --git a/frontend/src/scenes/data-warehouse/editor/bi/components/BIShelfDropTarget.tsx b/frontend/src/scenes/data-warehouse/editor/bi/components/BIShelfDropTarget.tsx index 9484b9d3c1d5..8d01aadf80e4 100644 --- a/frontend/src/scenes/data-warehouse/editor/bi/components/BIShelfDropTarget.tsx +++ b/frontend/src/scenes/data-warehouse/editor/bi/components/BIShelfDropTarget.tsx @@ -63,6 +63,7 @@ export function BIShelfDropTarget({ }} onDrop={(event) => { event.preventDefault() + event.stopPropagation() clearActiveDropShelf(shelf) const pill = parseBIShelfPillDragData(event.dataTransfer.getData(BI_SHELF_PILL_DRAG_MIME_TYPE)) if (pill) { diff --git a/frontend/src/scenes/data-warehouse/editor/bi/components/BIShowMe.tsx b/frontend/src/scenes/data-warehouse/editor/bi/components/BIShowMe.tsx index 9ac061eddc73..3c58025d83dc 100644 --- a/frontend/src/scenes/data-warehouse/editor/bi/components/BIShowMe.tsx +++ b/frontend/src/scenes/data-warehouse/editor/bi/components/BIShowMe.tsx @@ -1,5 +1,4 @@ import { useActions, useValues } from 'kea' -import { useState } from 'react' import { IconX } from '@posthog/icons' import { LemonButton } from '@posthog/lemon-ui' @@ -14,10 +13,9 @@ import { getChartTypeOptions } from '../biEditorOptions' /** Chart picker that highlights the chart types that suit the fields on the shelves. */ export function BIShowMe({ docked }: { docked: boolean }): JSX.Element { - const { chartFits, config } = useValues(biEditorLogic) - const { setChartType, setShowMeOpen } = useActions(biEditorLogic) + const { chartFits, config, hoveredChartType } = useValues(biEditorLogic) + const { setChartType, setShowMeOpen, setHoveredChartType } = useActions(biEditorLogic) const { featureFlags } = useValues(featureFlagLogic) - const [hoveredChartType, setHoveredChartType] = useState(null) const options = getChartTypeOptions(featureFlags) const describedOption = diff --git a/frontend/src/scenes/data-warehouse/editor/sqlEditorLogic.test.ts b/frontend/src/scenes/data-warehouse/editor/sqlEditorLogic.test.ts index 7e5fd6e9d3c4..4d5052f1acbb 100644 --- a/frontend/src/scenes/data-warehouse/editor/sqlEditorLogic.test.ts +++ b/frontend/src/scenes/data-warehouse/editor/sqlEditorLogic.test.ts @@ -2179,6 +2179,99 @@ describe('sqlEditorLogic', () => { biLogic.unmount() }) + it('hydrates fields when the schema arrives after restoring a worksheet', async () => { + await expectLogic(databaseLogic).toFinishAllListeners() + const biLogic = biEditorLogic({ tabId: TAB_ID }) + biLogic.mount() + biLogic.actions.restoreState({ editorView: BIEditorView.BI, config }) + expect(biLogic.values.dataPaneFields.dimensions).toEqual([]) + + queryEndpointMock.mockReturnValue([ + 200, + { + tables: { + events: { + id: 'events', + name: 'events', + type: 'posthog', + fields: { + event: { name: 'event', type: 'string', schema_valid: true }, + }, + }, + }, + joins: [], + }, + ]) + useMocks({ post: { '/api/environments/:team_id/query/DatabaseSchemaQuery/': queryEndpointMock } }) + databaseLogic.actions.setDatabaseFieldsComplete(false) + + await expectLogic(databaseLogic, () => + databaseLogic.actions.loadDatabaseSuccess({ + tables: { events: { id: 'events', name: 'events', type: 'posthog', fields: {} } }, + joins: [], + }) + ).toDispatchActions(['hydrateTableFieldsSuccess']) + + expect(biLogic.values.dataPaneFields.dimensions).toEqual([ + expect.objectContaining({ name: 'event', expression: 'event' }), + ]) + biLogic.unmount() + }) + + test.each(['disable', 'sql', 'clear', 'unchanged'] as const)( + 'handles a pending automatic query when the worksheet is %s', + async (transition) => { + logic = sqlEditorLogic({ tabId: TAB_ID, monaco: createMockMonaco(), editor: createMockEditor() }) + logic.mount() + const biLogic = biEditorLogic({ tabId: TAB_ID }) + biLogic.mount() + biLogic.actions.restoreState({ editorView: BIEditorView.BI, config }) + const runQuery = jest.spyOn(logic.actions, 'runQuery') + jest.useFakeTimers() + try { + biLogic.actions.setAutoUpdate(true) + biLogic.actions.setLimit(10000) + if (transition === 'disable') { + biLogic.actions.setAutoUpdate(false) + } else if (transition === 'sql') { + biLogic.actions.setEditorView(BIEditorView.SQL) + logic.actions.setQueryInput('SELECT 42') + } else if (transition === 'clear') { + biLogic.actions.resetConfig() + } + await jest.advanceTimersByTimeAsync(500) + expect(runQuery).toHaveBeenCalledTimes(transition === 'unchanged' ? 1 : 0) + } finally { + jest.useRealTimers() + runQuery.mockRestore() + biLogic.unmount() + } + } + ) + + it('clears the displayed result when clearing the worksheet', async () => { + logic = sqlEditorLogic({ tabId: TAB_ID, monaco: createMockMonaco(), editor: createMockEditor() }) + logic.mount() + const biLogic = biEditorLogic({ tabId: TAB_ID }) + biLogic.mount() + biLogic.actions.restoreState({ editorView: BIEditorView.BI, config }) + const dataLogic = dataNodeLogic({ + key: `data-warehouse-editor-data-node-${TAB_ID}`, + query: { kind: NodeKind.HogQLQuery, query: 'SELECT 1' }, + autoLoad: false, + }) + dataLogic.mount() + dataLogic.actions.setResponse({ results: [[1]], columns: ['1'], types: ['Int64'] }) + expect(dataLogic.values.response).not.toBeNull() + + await expectLogic(biLogic, () => biLogic.actions.resetConfig()).toFinishAllListeners() + + expect(dataLogic.values.response).toBeNull() + expect(logic.values.queryInput).toBe('') + dataLogic.unmount() + biLogic.unmount() + }) + it('restores BI mode and configuration from the URL and keeps changes in the hash', async () => { logic = sqlEditorLogic({ tabId: TAB_ID, From be7db4ea4ecfe4ebb69f834e024690c743506cdf Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:51:26 +0000 Subject: [PATCH 18/48] chore(autoresearch): deploy autoresearch code to the self-driving worker Add products/autoresearch/backend/** to the selfDriving container-image path filter, and keep it in generalPurpose while both workers register autoresearch. Include the clipped capture error_description in the InferenceRunError for a failed or partial prediction emit, so a transport failure records the underlying exception text. Closes #110837 Co-Authored-By: Claude Opus 5.5 Generated-By: PostHog Desktop Task-Id: 738aed62-67b4-4867-adb2-40a71130ad17 --- .github/workflows/container-images-cd.yml | 1 + .../autoresearch/backend/inference/scoring.py | 9 ++++++++- .../backend/inference/test_inference.py | 19 ++++++++++++++++--- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/.github/workflows/container-images-cd.yml b/.github/workflows/container-images-cd.yml index 55a02c41e87d..967afb5f24b3 100644 --- a/.github/workflows/container-images-cd.yml +++ b/.github/workflows/container-images-cd.yml @@ -515,6 +515,7 @@ jobs: selfDriving: - 'products/signals/backend/**' - 'products/signals/skills/**' + - 'products/autoresearch/backend/**' - 'posthog/settings/temporal.py' - 'posthog/temporal/common/**' - 'posthog/management/commands/start_temporal_worker.py' diff --git a/products/autoresearch/backend/inference/scoring.py b/products/autoresearch/backend/inference/scoring.py index aa9ac74eba51..3499def60cda 100644 --- a/products/autoresearch/backend/inference/scoring.py +++ b/products/autoresearch/backend/inference/scoring.py @@ -94,6 +94,8 @@ class InferenceRunError(Exception): _RESERVED_COLS = frozenset({"distinct_id", _LABEL_COL, _FOLD_COL}) # The score columns scoring adds to a feature row, kept out of the features hash. _SCORE_KEYS = frozenset({"p_y", "p_y_raw"}) +# A capture error description can hold a URL and an exception repr, so the run error clips it. +_MAX_EMIT_ERROR_DESCRIPTION_CHARS = 200 # Namespace for deterministic prediction event UUIDs, so a retried scoring activity @@ -500,11 +502,16 @@ def _emit_predictions( error=result.error, ) sample = [result.results.get(uid) for uid in result.warnings[:3]] + error_detail = "" + if result.error: + error_detail = f", {result.error.get('error')}" + if description := result.error.get("error_description"): + error_detail += f": {str(description)[:_MAX_EMIT_ERROR_DESCRIPTION_CHARS]}" raise InferenceRunError( f"Prediction events were not all accepted ({len(result.dropped)} dropped, " f"{len(result.retried)} exhausted retries, {len(result.unaccounted)} unaccounted, " f"{len(result.warnings)} stored with a warning{f' e.g. {sample!r}' if sample else ''}" - f"{', ' + str(result.error.get('error')) if result.error else ''}); failing the run so it is retried" + f"{error_detail}); failing the run so it is retried" ) return _EmitResult( diff --git a/products/autoresearch/backend/inference/test_inference.py b/products/autoresearch/backend/inference/test_inference.py index fa2e5c6d616c..89121478e625 100644 --- a/products/autoresearch/backend/inference/test_inference.py +++ b/products/autoresearch/backend/inference/test_inference.py @@ -195,13 +195,24 @@ def test_run_inference_zero_rows_completes_without_emitting(self): @parameterized.expand( [ - ("transport_failure", Exception("capture unavailable"), None), + ("transport_failure", Exception("capture unavailable"), None, "capture unavailable"), + ( + "transport_error_result", + None, + lambda events: CaptureInternalResult( + status_code=0, + error={"error": "transport_error", "error_description": "Connection refused " + "x" * 500}, + unaccounted=[event["event_uuid"] for event in events], + ), + "transport_error: Connection refused " + "x" * 181 + ")", + ), ( "one_event_dropped", None, lambda events: CaptureInternalResult( status_code=200, ok=[events[0]["event_uuid"]], dropped=[events[1]["event_uuid"]] ), + "1 dropped", ), ( "one_event_stored_with_a_warning", @@ -212,20 +223,22 @@ def test_run_inference_zero_rows_completes_without_emitting(self): warnings=[events[1]["event_uuid"]], results={events[1]["event_uuid"]: {"result": "warning", "message": "person processing disabled"}}, ), + "person processing disabled", ), ] ) - def test_any_emit_failure_fails_the_run(self, _name, side_effect, result_for): + def test_any_emit_failure_fails_the_run(self, _name, side_effect, result_for, expected_message): # Completing with a partial batch advanced last_scored_at past the people who never # received their prediction; the deterministic UUIDs make a full replay safe instead. pipeline, model = self._make_pipeline_and_model() capture = MagicMock(side_effect=side_effect or (lambda **kwargs: result_for(kwargs["events"]))) - with self.assertRaises(InferenceRunError): + with self.assertRaisesMessage(InferenceRunError, expected_message): self._run_live(pipeline, model, capture) run = AutoresearchRun.objects.filter(pipeline=pipeline).latest("created_at") assert run.status == AutoresearchRun.Status.FAILED + assert expected_message in run.error pipeline.refresh_from_db() assert pipeline.last_scored_at is None From 144bfa7bba8c4d6559dd49b673ddb676e640b91f Mon Sep 17 00:00:00 2001 From: Shy Alter Date: Fri, 2 Oct 2026 15:53:11 +0200 Subject: [PATCH 19/48] chore(canvas): match the generated kea types in canvasCommentsLogic CI runs Kea typegen and then checks for a clean tree. The hand-written activateThread type used CanvasRect where typegen writes the object shape, so the schema diff checks failed. Co-Authored-By: Claude Opus 5.5 Generated-By: PostHog Desktop Task-Id: b999fc3e-c145-4e0e-9851-6ef273201d04 --- .../frontend/sidePanel/comments/canvasCommentsLogic.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/products/canvas/frontend/sidePanel/comments/canvasCommentsLogic.ts b/products/canvas/frontend/sidePanel/comments/canvasCommentsLogic.ts index f614ac3d4d4d..64355cffbf90 100644 --- a/products/canvas/frontend/sidePanel/comments/canvasCommentsLogic.ts +++ b/products/canvas/frontend/sidePanel/comments/canvasCommentsLogic.ts @@ -82,7 +82,12 @@ export interface canvasCommentsLogicActions { source: CanvasThreadSource ) => { id: string - rect: CanvasRect | null + rect: { + bottom: number + left: number + right: number + top: number + } | null source: CanvasThreadSource } createComment: () => { From 948d3747c5d4e69346d70702a95463d17a7401eb Mon Sep 17 00:00:00 2001 From: Radu Raicea Date: Fri, 2 Oct 2026 10:00:45 -0400 Subject: [PATCH 20/48] fix(aio): retry custom provider rate limits in evaluations --- .../internal/ai-observability-judge-inputs.md | 3 +- .../ai_observability/evaluation_llm_judge.py | 4 +- .../temporal/ai_observability/run_tagger.py | 7 ++ .../ai_observability/test_run_evaluation.py | 73 ++++++++++++++++--- .../ai_observability/test_run_tagger.py | 42 +++++++---- .../backend/api/test/test_proxy.py | 2 +- .../ai_observability/backend/llm/errors.py | 19 +++++ .../llm/providers/openai_compatible.py | 7 +- .../providers/test/test_openai_compatible.py | 16 +++- .../backend/llm/system_one.py | 19 +---- 10 files changed, 143 insertions(+), 49 deletions(-) diff --git a/docs/internal/ai-observability-judge-inputs.md b/docs/internal/ai-observability-judge-inputs.md index 7d3a14659e2d..01ca7c7629e6 100644 --- a/docs/internal/ai-observability-judge-inputs.md +++ b/docs/internal/ai-observability-judge-inputs.md @@ -48,7 +48,8 @@ Responses, including errors and streamed completions, are limited to 1 MiB. The endpoint must return uncompressed responses; compressed responses are rejected before decoding. Expired requests, rejected responses, and streams closed by the caller close their underlying connection. -The OpenAI SDK does not retry custom-provider requests. Online evaluations use their existing Temporal retry policy for transient failures, and worker cancellation propagates to Temporal. +The OpenAI SDK does not retry custom-provider requests. Online evaluations and taggers use their existing Temporal retry policies for transient failures, and worker cancellation propagates to Temporal. +Rate-limit responses retry without disabling the evaluation or marking its connection invalid, honoring `Retry-After` up to one minute. Quota and authentication errors keep their existing terminal behavior. Models without native structured-output support retain the JSON fallback, which can make one additional bounded request. Oversized or compressed completion responses skip the evaluation as a rejected request without disabling the connection. The evaluation records the response limit and how to configure the endpoint. diff --git a/posthog/temporal/ai_observability/evaluation_llm_judge.py b/posthog/temporal/ai_observability/evaluation_llm_judge.py index 92be5cd303b5..1b7dad28fb87 100644 --- a/posthog/temporal/ai_observability/evaluation_llm_judge.py +++ b/posthog/temporal/ai_observability/evaluation_llm_judge.py @@ -58,6 +58,7 @@ ProviderRequestRejectedError, QuotaExceededError, RateLimitError, + RetryableRateLimitError, StructuredOutputParseError, UnsupportedModelError, provider_error_detail, @@ -65,7 +66,6 @@ from products.ai_observability.backend.llm.system_one import ( SystemOneClient, SystemOneEndpointBlockedError, - SystemOneRateLimitError, system_one_evaluations_enabled, ) from products.ai_observability.backend.llm.types import CompletionResponse @@ -743,7 +743,7 @@ def call_llm_judge( reasoning=str(e), skip_reason="request_rejected", ) - except SystemOneRateLimitError as e: + except RetryableRateLimitError as e: increment_errors("rate_limit", provider=provider) raise ApplicationError( str(e), diff --git a/posthog/temporal/ai_observability/run_tagger.py b/posthog/temporal/ai_observability/run_tagger.py index 9543590e7f5b..3b0aca322eb3 100644 --- a/posthog/temporal/ai_observability/run_tagger.py +++ b/posthog/temporal/ai_observability/run_tagger.py @@ -30,6 +30,7 @@ ProviderRequestRejectedError, QuotaExceededError, RateLimitError, + RetryableRateLimitError, StructuredOutputParseError, ) from products.ai_observability.backend.models.provider_keys import LLMProviderKey @@ -320,6 +321,12 @@ def execute_tagger_activity(inputs: ExecuteTaggerInputs) -> dict[str, Any]: non_retryable=True, ) raise + except RetryableRateLimitError as e: + raise ApplicationError( + str(e), + {"error_type": "provider_unavailable", "provider": provider}, + next_retry_delay=timedelta(seconds=e.retry_after) if e.retry_after is not None else None, + ) from e except RateLimitError: if is_byok: raise ApplicationError( diff --git a/posthog/temporal/ai_observability/test_run_evaluation.py b/posthog/temporal/ai_observability/test_run_evaluation.py index f80ad8c2d773..45cfe128d044 100644 --- a/posthog/temporal/ai_observability/test_run_evaluation.py +++ b/posthog/temporal/ai_observability/test_run_evaluation.py @@ -657,9 +657,43 @@ def test_provider_rejections_distinguish_blocked_endpoints_from_bad_inputs( assert "uncompressed responses no larger than 1 MiB" in result["reasoning"] -def test_system_one_rate_limit_retries_without_disabling_the_evaluation() -> None: +@pytest.mark.parametrize( + "provider, success_payload", + [ + ( + "system_one", + { + "model": "example-judge-v1", + "answers": {"verdict": {"type": "noul", "noul": 0.9}}, + "usage": {"input_tokens": 12, "output_tokens": 0}, + }, + ), + ( + "openai_compatible", + { + "id": "fixture", + "object": "chat.completion", + "created": 0, + "model": "example-judge-v1", + "choices": [ + { + "index": 0, + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": json.dumps({"verdict": True, "reasoning": "Polite greeting"}), + }, + } + ], + }, + ), + ], +) +def test_custom_provider_rate_limit_retries_without_disabling_the_evaluation( + provider: str, success_payload: dict[str, Any] +) -> None: key = MagicMock( - provider="system_one", + provider=provider, encrypted_config={"api_key": "example-token", "base_url": "https://decisions.example.com/v1"}, ) with ( @@ -670,22 +704,41 @@ def test_system_one_rate_limit_retries_without_disabling_the_evaluation() -> Non ), patch( "httpx.AsyncHTTPTransport.handle_async_request", - return_value=httpx.Response(429, headers={"Retry-After": "15"}, stream=httpx.ByteStream(b"")), - ), - pytest.raises(ApplicationError) as error, + side_effect=[ + httpx.Response(429, headers={"Retry-After": "15"}, stream=httpx.ByteStream(b"")), + httpx.Response( + 200, + headers={"Content-Type": "application/json"}, + stream=httpx.ByteStream(json.dumps(success_payload).encode()), + ), + ], + ) as transport, ): spec.return_value.resolve.return_value = MagicMock( - provider="system_one", model="example-judge-v1", provider_key=key, is_byok=True + provider=provider, model="example-judge-v1", provider_key=key, is_byok=True ) - call_llm_judge( + with pytest.raises(ApplicationError) as error: + call_llm_judge( + evaluation={"team_id": 1, "evaluation_config": {"prompt": "Polite?"}}, + system_prompt="", + user_prompt="Hello!", + allows_na=False, + ) + assert not error.value.non_retryable + assert error.value.next_retry_delay == timedelta(seconds=15) + assert terminal_user_error_result_from_application_error(error.value, allows_na=False) is None + assert transport.call_count == 1 + + result = call_llm_judge( evaluation={"team_id": 1, "evaluation_config": {"prompt": "Polite?"}}, system_prompt="", user_prompt="Hello!", allows_na=False, ) - assert not error.value.non_retryable - assert error.value.next_retry_delay == timedelta(seconds=15) - assert terminal_user_error_result_from_application_error(error.value, allows_na=False) is None + assert result["verdict"] is True + assert "terminal_user_error" not in result + assert "provider_key_state" not in result + assert transport.call_count == 2 def _openai_status_error(status: int, message: str) -> openai.APIStatusError: diff --git a/posthog/temporal/ai_observability/test_run_tagger.py b/posthog/temporal/ai_observability/test_run_tagger.py index 066b922ec7e3..8cedc164c547 100644 --- a/posthog/temporal/ai_observability/test_run_tagger.py +++ b/posthog/temporal/ai_observability/test_run_tagger.py @@ -1,7 +1,7 @@ import json import uuid import asyncio -from datetime import datetime +from datetime import datetime, timedelta from typing import Any, TypedDict import pytest @@ -852,7 +852,8 @@ def test_unusable_reply_is_skipped_not_captured( mock_capture_exception.assert_not_called() -def test_custom_provider_tagger_uses_bounded_completion() -> None: +@pytest.mark.parametrize("rate_limited", [False, True]) +def test_custom_provider_tagger_uses_bounded_completion(rate_limited: bool) -> None: key = LLMProviderKey( id=uuid.uuid4(), provider="openai_compatible", @@ -877,25 +878,36 @@ def test_custom_provider_tagger_uses_bounded_completion() -> None: ], } ).encode() + responses = [httpx.Response(200, stream=httpx.ByteStream(payload))] + if rate_limited: + responses.insert(0, httpx.Response(429, headers={"Retry-After": "15"}, stream=httpx.ByteStream(b""))) + inputs = ExecuteTaggerInputs( + tagger={ + "id": "test-tagger", + "team_id": 1, + "tagger_config": make_tagger_config(), + "model_configuration": {"provider": "openai_compatible", "model": "some-model"}, + }, + event_data=create_mock_event_data(1), + ) with ( patch.object(key, "save"), patch("posthog.temporal.ai_observability.model_resolution.EvaluationConfig") as configs, patch( "httpx.AsyncHTTPTransport.handle_async_request", - return_value=httpx.Response(200, stream=httpx.ByteStream(payload)), - ), + side_effect=responses, + ) as transport, ): configs.objects.get_or_create.return_value = (MagicMock(active_provider_key=key), False) - result = execute_tagger_activity( - ExecuteTaggerInputs( - tagger={ - "id": "test-tagger", - "team_id": 1, - "tagger_config": make_tagger_config(), - "model_configuration": {"provider": "openai_compatible", "model": "some-model"}, - }, - event_data=create_mock_event_data(1), - ) - ) + if rate_limited: + with pytest.raises(ApplicationError) as error: + execute_tagger_activity(inputs) + assert not error.value.non_retryable + assert error.value.next_retry_delay == timedelta(seconds=15) + assert error.value.details == ({"error_type": "provider_unavailable", "provider": "openai_compatible"},) + assert transport.call_count == 1 + result = execute_tagger_activity(inputs) assert result["tags"] == ["billing"] assert result["reasoning"] == "Billing question" + assert key.state == LLMProviderKey.State.OK + assert transport.call_count == (2 if rate_limited else 1) diff --git a/products/ai_observability/backend/api/test/test_proxy.py b/products/ai_observability/backend/api/test/test_proxy.py index d90c56969ae3..90f12e448419 100644 --- a/products/ai_observability/backend/api/test/test_proxy.py +++ b/products/ai_observability/backend/api/test/test_proxy.py @@ -68,7 +68,7 @@ async def aclose(self) -> None: ): response = await asyncio.to_thread(view._create_streaming_response, stream) assert isinstance(response, StreamingHttpResponse) - iterator = cast(AsyncGenerator[bytes], aiter(response._iterator)) + iterator = cast(AsyncGenerator[bytes], aiter(response._iterator)) # type: ignore[attr-defined] first_chunk = asyncio.Event() async def consume() -> None: diff --git a/products/ai_observability/backend/llm/errors.py b/products/ai_observability/backend/llm/errors.py index 4d3ca91aa815..e9fa593091f9 100644 --- a/products/ai_observability/backend/llm/errors.py +++ b/products/ai_observability/backend/llm/errors.py @@ -1,4 +1,7 @@ +import math import logging +from datetime import UTC, datetime +from email.utils import parsedate_to_datetime from products.ai_observability.backend.llm.types import StreamChunk @@ -31,6 +34,22 @@ class RateLimitError(LLMError): """Raised when rate limit is exceeded""" +class RetryableRateLimitError(RateLimitError): + def __init__(self, message: str, retry_after: str | None = None) -> None: + super().__init__(message) + self.retry_after: float | None = None + if retry_after: + try: + delay = float(retry_after) + except ValueError: + try: + delay = (parsedate_to_datetime(retry_after) - datetime.now(UTC)).total_seconds() + except (ValueError, TypeError, OverflowError): + return + if math.isfinite(delay): + self.retry_after = max(1, min(delay, 60)) + + class QuotaExceededError(LLMError): """Raised when API quota is exceeded""" diff --git a/products/ai_observability/backend/llm/providers/openai_compatible.py b/products/ai_observability/backend/llm/providers/openai_compatible.py index fbb98afba5bc..d3b5599a9d87 100644 --- a/products/ai_observability/backend/llm/providers/openai_compatible.py +++ b/products/ai_observability/backend/llm/providers/openai_compatible.py @@ -31,6 +31,8 @@ ProviderConfigurationError, ProviderRequestRejectedError, ProviderTimeoutError, + RateLimitError, + RetryableRateLimitError, error_field_for_message, ) from products.ai_observability.backend.llm.providers._diagnostics import tagged_http_client @@ -152,7 +154,10 @@ def _mapped_error(self, error: Exception, model: str) -> LLMError | None: return ProviderRequestRejectedError(RESPONSE_LIMIT_MESSAGE) if isinstance(cause, httpx.TimeoutException): return ProviderTimeoutError(self.request_timeout) - return super()._mapped_error(error, model) + mapped = super()._mapped_error(error, model) + if isinstance(error, openai.RateLimitError) and isinstance(mapped, RateLimitError): + return RetryableRateLimitError(str(error), error.response.headers.get("Retry-After")) + return mapped def complete( self, diff --git a/products/ai_observability/backend/llm/providers/test/test_openai_compatible.py b/products/ai_observability/backend/llm/providers/test/test_openai_compatible.py index 887af36507ee..9f5354ffd0cb 100644 --- a/products/ai_observability/backend/llm/providers/test/test_openai_compatible.py +++ b/products/ai_observability/backend/llm/providers/test/test_openai_compatible.py @@ -17,9 +17,11 @@ from products.ai_observability.backend.llm.errors import ( AuthenticationError, + LLMError, ProviderConfigurationError, ProviderRequestRejectedError, ProviderTimeoutError, + QuotaExceededError, ) from products.ai_observability.backend.llm.providers import openai_compatible from products.ai_observability.backend.llm.providers.openai_compatible import ( @@ -370,11 +372,19 @@ def test_list_models_logs_total_deadline(self) -> None: assert isinstance(error, openai.APIConnectionError) assert isinstance(error.__cause__, httpx.TimeoutException) - def test_complete_preserves_authentication_errors(self) -> None: - response = httpx.Response(401, stream=httpx.ByteStream(b'{"error":{"message":"Invalid API key"}}')) + @parameterized.expand( + [ + (401, {"message": "Invalid API key"}, AuthenticationError), + (429, {"message": "Quota exceeded", "code": "insufficient_quota"}, QuotaExceededError), + ] + ) + def test_complete_preserves_permanent_errors( + self, status: int, error_body: dict[str, str], expected_error: type[LLMError] + ) -> None: + response = httpx.Response(status, stream=httpx.ByteStream(json.dumps({"error": error_body}).encode())) with ( patch("httpx.AsyncHTTPTransport.handle_async_request", return_value=response), - pytest.raises(AuthenticationError), + pytest.raises(expected_error), ): OpenAICompatibleAdapter(base_url=ALLOWED_BASE_URL).complete( _completion_request(), "test-key", AnalyticsContext(capture=False) diff --git a/products/ai_observability/backend/llm/system_one.py b/products/ai_observability/backend/llm/system_one.py index 74f80f7db869..0bbed5646da9 100644 --- a/products/ai_observability/backend/llm/system_one.py +++ b/products/ai_observability/backend/llm/system_one.py @@ -1,7 +1,4 @@ -import math from collections.abc import Mapping -from datetime import UTC, datetime -from email.utils import parsedate_to_datetime from urllib.parse import urlsplit from django.conf import settings @@ -32,6 +29,7 @@ ProviderConnectionError, ProviderRequestRejectedError, RateLimitError, + RetryableRateLimitError, StructuredOutputParseError, is_context_window_error_message, ) @@ -71,20 +69,9 @@ class SystemOneEndpointBlockedError(LLMError): pass -class SystemOneRateLimitError(RateLimitError): +class SystemOneRateLimitError(RetryableRateLimitError): def __init__(self, retry_after: str | None) -> None: - super().__init__("The System One endpoint is temporarily unavailable. Try again later.") - self.retry_after: float | None = None - if retry_after: - try: - delay = float(retry_after) - except ValueError: - try: - delay = (parsedate_to_datetime(retry_after) - datetime.now(UTC)).total_seconds() - except (ValueError, TypeError, OverflowError): - return - if math.isfinite(delay): - self.retry_after = max(1, min(delay, 60)) + super().__init__("The System One endpoint is temporarily unavailable. Try again later.", retry_after) class SystemOneClient: From d0e3b9fd9ac49d5426635a0b3faa94ca9ab0b7ea Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:01:24 +0000 Subject: [PATCH 21/48] chore(visual): update storybook baselines 2 updated Run: 30eba949-dfb9-45e9-aa26-8893df928672 Co-authored-by: mariusandra <53387+mariusandra@users.noreply.github.com> --- frontend/snapshots.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/snapshots.yml b/frontend/snapshots.yml index 4c3b2f2078d2..58be29077545 100644 --- a/frontend/snapshots.yml +++ b/frontend/snapshots.yml @@ -9355,9 +9355,9 @@ snapshots: scenes-app-data-warehouse-settings-schemas--multi-schema--light: hash: v1.k794b7964.e5d678194a8e191fdf4f8a92cd7328f1e3cb162a81d3c4d05ef608f3c61537da.ZshlecoDn8CpsaVHaONi7UiXq8r-Xi4zJSWKeg7nAP0 scenes-app-data-warehouse-sql-editor--bi-mode-worksheet--dark: - hash: v1.k794b7964.ed1827730f8473edfc25b095242a52b69c0f567ae6e7473e8830792f56e4f00b.uBeh0xaWPYyrbED7Bccx7pkJ0uDHxeX92qCsI6VixuA + hash: v1.k794b7964.c658d01e1ed459e380f64ae18cc893a3bdd4d14d60924691224143b4133d304c.bJ8fJvADbAl-CJHuO6-1i9Ng1abedjo0nuR3uHQ42KM scenes-app-data-warehouse-sql-editor--bi-mode-worksheet--light: - hash: v1.k794b7964.c0d402abf71c4b0869bf2e88773812a4b36945a63bae947d512fd48d7b6422fa.ANiEHn-ocUTcXvHPyYKVv9Mayy2gO187S1lLhRt3cuY + hash: v1.k794b7964.e6e8b6fc3608d3bd734534aae37eb9a8c3d56f5979791ce904a8cf0e18f11f88.S4-L9C5xAT431duxCiXdzDM4sJw1eJ4URMS5uZnhUMI scenes-app-data-warehouse-sql-editor--lazy-schema--dark: hash: v1.k794b7964.8c150639468430cd106d26a5e3dde526b0d08bde8d07f37527113386e022d97b.xTBWyA6WWMQjVdb8wJvusy7_4oieQVnkHe2FCJxpQHg scenes-app-data-warehouse-sql-editor--lazy-schema--light: From 6e3b0b2a076c7e96e90f99598ae77db6bc722fca Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:05:23 +0000 Subject: [PATCH 22/48] fix(autoresearch): keep both ends of a clipped emit error description A requests transport error starts with the target host and ends with the cause, such as the errno. The head-only clip cut the cause, so a refused connection and a failed DNS lookup gave the same run error. The clip now keeps the start and the end of the description inside the same 200-character limit. The emit-failure test now uses a requests-style refused-connection message and checks that the errno survives the clip. Co-Authored-By: Claude Opus 5.5 Generated-By: PostHog Desktop Task-Id: 30cf875e-5854-447b-b60d-3fdb1f89e4c3 --- .../autoresearch/backend/inference/scoring.py | 16 +++++++++++++++- .../backend/inference/test_inference.py | 11 +++++++++-- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/products/autoresearch/backend/inference/scoring.py b/products/autoresearch/backend/inference/scoring.py index 3499def60cda..2f763a7dec18 100644 --- a/products/autoresearch/backend/inference/scoring.py +++ b/products/autoresearch/backend/inference/scoring.py @@ -103,6 +103,20 @@ class InferenceRunError(Exception): _PREDICTION_UUID_NAMESPACE = uuid.UUID("6f9a4a24-0e5c-4a5a-9d0e-2f6a0f0b1c3d") +def _clip_middle(text: str, limit: int) -> str: + """ + Keep both ends of ``text``. A requests transport error starts with the target host and + ends with the cause, such as ``[Errno 111] Connection refused``, so a head-only clip + makes a refused connection and a failed DNS lookup look the same. + """ + if len(text) <= limit: + return text + marker = "..." + head = (limit - len(marker)) // 2 + tail = limit - len(marker) - head + return f"{text[:head]}{marker}{text[-tail:]}" + + def _is_uuid(value: str) -> bool: try: uuid.UUID(str(value)) @@ -506,7 +520,7 @@ def _emit_predictions( if result.error: error_detail = f", {result.error.get('error')}" if description := result.error.get("error_description"): - error_detail += f": {str(description)[:_MAX_EMIT_ERROR_DESCRIPTION_CHARS]}" + error_detail += f": {_clip_middle(str(description), _MAX_EMIT_ERROR_DESCRIPTION_CHARS)}" raise InferenceRunError( f"Prediction events were not all accepted ({len(result.dropped)} dropped, " f"{len(result.retried)} exhausted retries, {len(result.unaccounted)} unaccounted, " diff --git a/products/autoresearch/backend/inference/test_inference.py b/products/autoresearch/backend/inference/test_inference.py index 89121478e625..75799e28f97b 100644 --- a/products/autoresearch/backend/inference/test_inference.py +++ b/products/autoresearch/backend/inference/test_inference.py @@ -53,6 +53,11 @@ {"distinct_id": "user-1", "events_total_30d": 50, "days_since_last_seen": 2}, {"distinct_id": "user-2", "events_total_30d": 10, "days_since_last_seen": 15}, ] +_REFUSED_CONNECTION_ERROR = ( + "HTTPConnectionPool(host='capture.example.com', port=8010): Max retries exceeded with url: " + "/i/v1/analytics/events (Caused by NewConnectionError(': Failed to establish a new connection: [Errno 111] Connection refused'))" +) def _accepted(events: list[dict]) -> CaptureInternalResult: @@ -201,10 +206,12 @@ def test_run_inference_zero_rows_completes_without_emitting(self): None, lambda events: CaptureInternalResult( status_code=0, - error={"error": "transport_error", "error_description": "Connection refused " + "x" * 500}, + error={"error": "transport_error", "error_description": _REFUSED_CONNECTION_ERROR}, unaccounted=[event["event_uuid"] for event in events], ), - "transport_error: Connection refused " + "x" * 181 + ")", + "transport_error: HTTPConnectionPool(host='capture.example.com', port=8010): Max retries exceeded " + "with url: /i/v1/an... object at 0x7f0000000000>: Failed to establish a new connection: " + "[Errno 111] Connection refused')))", ), ( "one_event_dropped", From 26a035a575a8fc5c2eba0e6d485af4607d14fab2 Mon Sep 17 00:00:00 2001 From: Robbie Date: Fri, 2 Oct 2026 15:12:15 +0100 Subject: [PATCH 23/48] feat(ai-research): send a us.posthog.com referer from the image fetcher (#110771) Co-authored-by: Claude Opus 5.5 From 8911ed00bce52a8688ecb98b2402eedf78a29d1c Mon Sep 17 00:00:00 2001 From: Frank Hamand Date: Fri, 2 Oct 2026 14:07:01 +0100 Subject: [PATCH 24/48] perf(metrics): read the hourly projection in the overview services query The services query scanned every series-hour row of the last day. The services_by_hour projection on metrics4_series holds the same rollup per team, hour and service, but the query shape kept ClickHouse from using it. The query now filters on time_bucket, counts series with uniq, and aggregates the bare last_seen column with convertToProjectTimezone off, converting to UTC outside max(). metric_series exposes time_bucket for the filter. Co-Authored-By: Claude Opus 5.5 Generated-By: PostHog Desktop Task-Id: 3538a544-5668-418a-aebd-c3430d18864a --- posthog/hogql/database/schema/metrics.py | 3 +++ .../backend/metrics_overview_query_runner.py | 19 ++++++++------ .../test_metrics_overview_query_runner.py | 25 +++++++++++++++++++ 3 files changed, 39 insertions(+), 8 deletions(-) diff --git a/posthog/hogql/database/schema/metrics.py b/posthog/hogql/database/schema/metrics.py index 2ec8d870e7db..8062d9772a78 100644 --- a/posthog/hogql/database/schema/metrics.py +++ b/posthog/hogql/database/schema/metrics.py @@ -274,6 +274,9 @@ class MetricSeriesTable(Table): "last_seen": DateTimeDatabaseField( name="timestamp", nullable=False, description="Most recent sample timestamp seen for this series." ), + "time_bucket": DateTimeDatabaseField( + name="time_bucket", nullable=False, description="Start of the UTC hour that contains `last_seen`." + ), "original_expiry_timestamp": DateTimeDatabaseField( name="original_expiry_timestamp", nullable=False, description="When the series leaves retention." ), diff --git a/products/metrics/backend/metrics_overview_query_runner.py b/products/metrics/backend/metrics_overview_query_runner.py index 90289bb0be84..c64f82909769 100644 --- a/products/metrics/backend/metrics_overview_query_runner.py +++ b/products/metrics/backend/metrics_overview_query_runner.py @@ -1,4 +1,4 @@ -"""No FINAL: `uniqExact` and `max(last_seen)` give the same result on unmerged duplicate rows.""" +"""No FINAL: the distinct counts and `max(last_seen)` give the same result on unmerged duplicate rows.""" import datetime as dt import contextvars @@ -7,7 +7,7 @@ from opentelemetry import trace from opentelemetry.trace import Span -from posthog.schema import HogQLQueryResponse +from posthog.schema import HogQLQueryModifiers, HogQLQueryResponse from posthog.hogql import ast from posthog.hogql.constants import HogQLGlobalSettings @@ -112,18 +112,20 @@ def _run_metric_names_count(self) -> int: def _run_services(self) -> _ServicesRollup: with tracer.start_as_current_span("metrics.overview.services") as span: span.set_attribute("team_id", self.team.pk) - # Each series has one service, so the sum of the service counts is exact. + # The services_by_hour projection answers this query only while it filters on time_bucket, uses uniq, + # and aggregates the bare last_seen column. The time zone conversion stays outside max() for that reason. + # Each series has one service, so the sum of the service counts counts each series once. query = parse_select( """ SELECT service_name, uniqExact(metric_name) AS metric_names, - uniqExact(series_fingerprint) AS series, - max(last_seen) AS last_seen_at, - sum(uniqExact(series_fingerprint)) OVER () AS total_series, - max(max(last_seen)) OVER () AS total_last_seen_at + uniq(series_fingerprint) AS series, + toTimeZone(max(last_seen), 'UTC') AS last_seen_at, + sum(uniq(series_fingerprint)) OVER () AS total_series, + toTimeZone(max(max(last_seen)) OVER (), 'UTC') AS total_last_seen_at FROM posthog.metric_series - WHERE last_seen > now() - {lookback} + WHERE time_bucket >= toStartOfHour(toTimeZone(now() - {lookback}, 'UTC')) GROUP BY service_name ORDER BY series DESC, service_name ASC LIMIT {limit} @@ -138,6 +140,7 @@ def _run_services(self) -> _ServicesRollup: team=self.team, workload=Workload.LOGS, settings=_QUERY_SETTINGS, + modifiers=HogQLQueryModifiers(convertToProjectTimezone=False), ) _set_query_timing_attributes(span, response) span.set_attribute("services.count", len(response.results)) diff --git a/products/metrics/backend/tests/test_metrics_overview_query_runner.py b/products/metrics/backend/tests/test_metrics_overview_query_runner.py index f3d59195110c..8943aa03fac7 100644 --- a/products/metrics/backend/tests/test_metrics_overview_query_runner.py +++ b/products/metrics/backend/tests/test_metrics_overview_query_runner.py @@ -8,6 +8,8 @@ from parameterized import parameterized from rest_framework import status +from posthog.clickhouse.client import sync_execute + from products.metrics.backend import metrics_overview_query_runner from products.metrics.backend.metrics_overview_query_runner import MetricsOverviewQueryRunner from products.metrics.backend.tests._seeder import seed_metric, seed_metric_event, truncate_metrics_tables @@ -65,6 +67,29 @@ def test_rolls_up_services_within_the_window(self, _name: str, max_services: int self.assertEqual(api_row.series, 3) self.assertEqual(dt.datetime.fromisoformat(api_row.last_seen), anchor) + def test_services_query_reads_the_hourly_projection(self): + anchor = timezone.now().replace(microsecond=0) - dt.timedelta(minutes=5) + seed_metric(team_id=self.team.id, metric_name="http.duration", points=[(anchor, 1.0)], service_name="api") + + MetricsOverviewQueryRunner(team=self.team).run() + + sync_execute("SYSTEM FLUSH LOGS") + rows = sync_execute( + """ + SELECT projections + FROM system.query_log + WHERE type = 'QueryFinish' + AND event_time > now() - INTERVAL 10 MINUTE + AND JSONExtractString(log_comment, 'query_type') = 'MetricsOverviewServicesQuery' + AND JSONExtractInt(log_comment, 'team_id') = %(team_id)s + ORDER BY event_time_microseconds DESC + LIMIT 1 + """, + {"team_id": self.team.id}, + ) + self.assertEqual(len(rows), 1) + self.assertTrue(any(name.endswith(".services_by_hour") for name in rows[0][0]), rows[0][0]) + def test_quiet_project_keeps_overall_last_seen_but_lists_no_services(self): stale = timezone.now().replace(microsecond=0) - dt.timedelta(days=3) seed_metric(team_id=self.team.id, metric_name="http.duration", points=[(stale, 1.0)], service_name="api") From de574c6c923776b50e0f3b57c874fe94eaa81c00 Mon Sep 17 00:00:00 2001 From: Frank Hamand Date: Fri, 2 Oct 2026 14:17:30 +0100 Subject: [PATCH 25/48] test(metrics): force the projection instead of reading the query log The overview test now runs the services query with force_optimize_projection, so ClickHouse rejects the query when the projection is not used. HogQL settings accept the new field. Co-Authored-By: Claude Opus 5.5 Generated-By: PostHog Desktop Task-Id: 3538a544-5668-418a-aebd-c3430d18864a --- posthog/hogql/constants.py | 1 + .../test_metrics_overview_query_runner.py | 32 ++++++++----------- 2 files changed, 14 insertions(+), 19 deletions(-) diff --git a/posthog/hogql/constants.py b/posthog/hogql/constants.py index 1baa49239c68..c5ce49c77d89 100644 --- a/posthog/hogql/constants.py +++ b/posthog/hogql/constants.py @@ -182,6 +182,7 @@ class HogQLQuerySettings(BaseModel): join_algorithm: Optional[str] = None grace_hash_join_initial_buckets: Optional[int] = None force_data_skipping_indices: Optional[list[str]] = None + force_optimize_projection: Optional[bool] = None load_balancing: Optional[str] = None format_csv_allow_double_quotes: Optional[bool] = None optimize_skip_unused_shards: Optional[bool] = None diff --git a/products/metrics/backend/tests/test_metrics_overview_query_runner.py b/products/metrics/backend/tests/test_metrics_overview_query_runner.py index 8943aa03fac7..7bfdfb8ac7da 100644 --- a/products/metrics/backend/tests/test_metrics_overview_query_runner.py +++ b/products/metrics/backend/tests/test_metrics_overview_query_runner.py @@ -1,4 +1,5 @@ import datetime as dt +from typing import Any from posthog.test.base import APIBaseTest, ClickhouseTestMixin from unittest.mock import patch @@ -8,7 +9,9 @@ from parameterized import parameterized from rest_framework import status -from posthog.clickhouse.client import sync_execute +from posthog.schema import HogQLQueryResponse + +from posthog.hogql.query import execute_hogql_query from products.metrics.backend import metrics_overview_query_runner from products.metrics.backend.metrics_overview_query_runner import MetricsOverviewQueryRunner @@ -71,24 +74,15 @@ def test_services_query_reads_the_hourly_projection(self): anchor = timezone.now().replace(microsecond=0) - dt.timedelta(minutes=5) seed_metric(team_id=self.team.id, metric_name="http.duration", points=[(anchor, 1.0)], service_name="api") - MetricsOverviewQueryRunner(team=self.team).run() - - sync_execute("SYSTEM FLUSH LOGS") - rows = sync_execute( - """ - SELECT projections - FROM system.query_log - WHERE type = 'QueryFinish' - AND event_time > now() - INTERVAL 10 MINUTE - AND JSONExtractString(log_comment, 'query_type') = 'MetricsOverviewServicesQuery' - AND JSONExtractInt(log_comment, 'team_id') = %(team_id)s - ORDER BY event_time_microseconds DESC - LIMIT 1 - """, - {"team_id": self.team.id}, - ) - self.assertEqual(len(rows), 1) - self.assertTrue(any(name.endswith(".services_by_hour") for name in rows[0][0]), rows[0][0]) + def force_projection(**kwargs: Any) -> HogQLQueryResponse: + if kwargs["query_type"] == "MetricsOverviewServicesQuery": + kwargs["settings"] = kwargs["settings"].model_copy(update={"force_optimize_projection": True}) + return execute_hogql_query(**kwargs) + + with patch.object(metrics_overview_query_runner, "execute_hogql_query", side_effect=force_projection): + overview = MetricsOverviewQueryRunner(team=self.team).run() + + self.assertEqual([(s.service_name, s.series) for s in overview.services], [("api", 1)]) def test_quiet_project_keeps_overall_last_seen_but_lists_no_services(self): stale = timezone.now().replace(microsecond=0) - dt.timedelta(days=3) From a4f679b0f1edcd2b5e9a2173e0b0db8212677f9e Mon Sep 17 00:00:00 2001 From: Radu Raicea Date: Fri, 2 Oct 2026 10:23:34 -0400 Subject: [PATCH 26/48] fix(aio): skip rejected tagger responses --- .../temporal/ai_observability/run_tagger.py | 1 + .../ai_observability/test_run_tagger.py | 31 ++++++++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/posthog/temporal/ai_observability/run_tagger.py b/posthog/temporal/ai_observability/run_tagger.py index 3b0aca322eb3..b4300e5807cb 100644 --- a/posthog/temporal/ai_observability/run_tagger.py +++ b/posthog/temporal/ai_observability/run_tagger.py @@ -676,6 +676,7 @@ async def run(self, inputs: RunTaggerInputs) -> dict[str, Any]: "key_invalid", "parse_error", "no_default_model", + "request_rejected", ): if error_type in ( "provider_key_required", diff --git a/posthog/temporal/ai_observability/test_run_tagger.py b/posthog/temporal/ai_observability/test_run_tagger.py index 8cedc164c547..996fd2163fff 100644 --- a/posthog/temporal/ai_observability/test_run_tagger.py +++ b/posthog/temporal/ai_observability/test_run_tagger.py @@ -8,7 +8,7 @@ from unittest.mock import MagicMock, patch import httpx -from temporalio.exceptions import ApplicationError +from temporalio.exceptions import ActivityError, ApplicationError from posthog.api.capture import CaptureInternalError from posthog.models import Organization, Team @@ -851,6 +851,35 @@ def test_unusable_reply_is_skipped_not_captured( assert is_expected_activity_failure(exc_info.value) mock_capture_exception.assert_not_called() + activity_error = ActivityError( + "Tagger activity failed", + scheduled_event_id=1, + started_event_id=2, + identity="test-worker", + activity_type="execute_tagger_activity", + activity_id="test-activity", + retry_state=None, + ) + activity_error.__cause__ = exc_info.value + with ( + patch("temporalio.workflow.deprecate_patch"), + patch("temporalio.workflow.now", return_value=datetime.now()), + patch("temporalio.workflow.execute_activity", side_effect=[tagger, activity_error]), + ): + result = asyncio.run( + RunTaggerWorkflow().run( + RunTaggerInputs(tagger_id=tagger["id"], event_data=create_mock_event_data(team.id)) + ) + ) + + assert result == { + "tags": [], + "skipped": True, + "skip_reason": error_type, + "message": str(llm_error), + "tagger_id": tagger["id"], + } + @pytest.mark.parametrize("rate_limited", [False, True]) def test_custom_provider_tagger_uses_bounded_completion(rate_limited: bool) -> None: From 05e63993fe504684cdac6b55dc01e347dfb3b176 Mon Sep 17 00:00:00 2001 From: Radu Raicea Date: Fri, 2 Oct 2026 10:30:25 -0400 Subject: [PATCH 27/48] chore(aio): pin tagger workflow test clock --- posthog/temporal/ai_observability/test_run_tagger.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/posthog/temporal/ai_observability/test_run_tagger.py b/posthog/temporal/ai_observability/test_run_tagger.py index 996fd2163fff..05e613e86295 100644 --- a/posthog/temporal/ai_observability/test_run_tagger.py +++ b/posthog/temporal/ai_observability/test_run_tagger.py @@ -1,7 +1,7 @@ import json import uuid import asyncio -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta from typing import Any, TypedDict import pytest @@ -863,7 +863,7 @@ def test_unusable_reply_is_skipped_not_captured( activity_error.__cause__ = exc_info.value with ( patch("temporalio.workflow.deprecate_patch"), - patch("temporalio.workflow.now", return_value=datetime.now()), + patch("temporalio.workflow.now", return_value=datetime(2026, 1, 1, tzinfo=UTC)), patch("temporalio.workflow.execute_activity", side_effect=[tagger, activity_error]), ): result = asyncio.run( From 82a38903a723ce57c12ad90e35b0bb13318cd788 Mon Sep 17 00:00:00 2001 From: Kyle Swank Date: Fri, 2 Oct 2026 10:32:02 -0400 Subject: [PATCH 28/48] fix(meta-ads): keep entity syncs retryable when meta refuses the smallest page Entity endpoints like ad_creatives have no date range to narrow. When Meta refused the smallest page, the sync raised the shrink-exhausted error, which is non-retryable and auto-disables the schema. That error is usually load on Meta's side and clears on a later attempt. The entity path now retries the smallest page with backoff, then raises a retryable error without Meta's raw body, so Temporal resumes from the saved cursor and the schema stays enabled. Co-Authored-By: Claude Opus 5.5 --- .../data_imports/sources/meta_ads/meta_ads.py | 33 +++++++++++- .../data_imports/sources/meta_ads/source.py | 6 +++ .../sources/meta_ads/test_meta_ads.py | 54 ++++++++++++++++--- 3 files changed, 84 insertions(+), 9 deletions(-) diff --git a/products/warehouse_sources/backend/temporal/data_imports/sources/meta_ads/meta_ads.py b/products/warehouse_sources/backend/temporal/data_imports/sources/meta_ads/meta_ads.py index 7ea556db1e55..6e48d61117ff 100644 --- a/products/warehouse_sources/backend/temporal/data_imports/sources/meta_ads/meta_ads.py +++ b/products/warehouse_sources/backend/temporal/data_imports/sources/meta_ads/meta_ads.py @@ -470,6 +470,14 @@ def _get_initial_request(url: str, params: dict) -> Response: # with the key there. SHRINK_EXHAUSTED_ERROR_MESSAGE = "Meta could not return this data even at the smallest request size" +# Entity endpoints (campaigns, ads, ad creatives, ...) have no date range to narrow, so the page +# limit is the only lever. When Meta still refuses the smallest page, the cause is load on Meta's +# side, and the same request later succeeds. So the entity path retries the smallest page with +# backoff, then raises this retryable marker. Temporal then resumes from the saved cursor, and the +# schema stays enabled for the next scheduled sync. +ENTITY_PAGE_REFUSED_ERROR_MESSAGE = "Meta could not return this page even at the smallest page size (retryable)" +SMALLEST_PAGE_LIMIT_MAX_RETRIES = 3 + def _parse_json_leniently(response: Response) -> dict | None: """Parse a Meta API response body as JSON, tolerating trailing garbage after it. @@ -585,6 +593,20 @@ def _raise_shrink_exhausted_error(response: Response) -> typing.NoReturn: raise Exception(f"{SHRINK_EXHAUSTED_ERROR_MESSAGE} (Meta API response: {response.status_code} - {response.text})") +def _raise_entity_page_refused_error(response: Response) -> typing.NoReturn: + """Raise once the entity path has retried its smallest page and Meta still refuses it. + + The message leaves out ``response.text`` on purpose. Meta's body carries "Please reduce the + amount of data you're asking for", which ``MetaAdsSource.get_non_retryable_errors`` matches + before the retryable patterns, so including it would disable the schema again. + """ + error = _meta_error_body(response) + raise Exception( + f"{ENTITY_PAGE_REFUSED_ERROR_MESSAGE} (Meta API response: {response.status_code}, " + f"code {error.get('code')}, subcode {error.get('error_subcode')}, fbtrace_id {error.get('fbtrace_id')})" + ) + + class MetaAdsAuthError(Exception): """Meta rejected the credentials or the permissions they carry (see `_is_permanent_auth_error`).""" @@ -677,6 +699,8 @@ def _iter_simple_pagination( fails on those accounts. Retrying the same URL at a smaller limit never re-emits already-yielded rows — the initial request has yielded nothing yet, and a cursor points at the start of the next (not-yet-yielded) page. + If the smallest limit still fails, the page is retried with backoff and + then raised as retryable (see ``ENTITY_PAGE_REFUSED_ERROR_MESSAGE``). """ access_token = params["access_token"] current_limit = PAGE_LIMIT_FALLBACK_SIZES[0] @@ -704,6 +728,7 @@ def _issue() -> Response: response = _issue() malformed_json_attempts = 0 + smallest_limit_retries = 0 while True: if response.status_code != 200: @@ -716,7 +741,12 @@ def _issue() -> Response: current_limit = smaller response = _issue() continue - _raise_shrink_exhausted_error(response) + if smallest_limit_retries < SMALLEST_PAGE_LIMIT_MAX_RETRIES: + smallest_limit_retries += 1 + _backoff_sleep(smallest_limit_retries) + response = _issue() + continue + _raise_entity_page_refused_error(response) _raise_meta_api_error(response) try: @@ -734,6 +764,7 @@ def _issue() -> Response: response = _issue() continue malformed_json_attempts = 0 + smallest_limit_retries = 0 yield response_payload.get("data", []) diff --git a/products/warehouse_sources/backend/temporal/data_imports/sources/meta_ads/source.py b/products/warehouse_sources/backend/temporal/data_imports/sources/meta_ads/source.py index 4302bc996472..dbf8b4fcb01a 100644 --- a/products/warehouse_sources/backend/temporal/data_imports/sources/meta_ads/source.py +++ b/products/warehouse_sources/backend/temporal/data_imports/sources/meta_ads/source.py @@ -40,6 +40,7 @@ MetaAdsSourceConfig, ) from products.warehouse_sources.backend.temporal.data_imports.sources.meta_ads.meta_ads import ( + ENTITY_PAGE_REFUSED_ERROR_MESSAGE, META_ADS_API_VERSION_V25, META_ADS_API_VERSION_V26, META_AUTH_ERROR_MESSAGE, @@ -194,6 +195,7 @@ def get_retryable_errors(self) -> set[str]: # for volume. Only waiting helps, and the sync already retries via Temporal, so this # shouldn't page us as a bug either. META_RATE_LIMIT_ERROR_MESSAGE, + ENTITY_PAGE_REFUSED_ERROR_MESSAGE, } def get_retry_exhausted_errors(self) -> dict[str, str]: @@ -209,6 +211,10 @@ def get_retry_exhausted_errors(self) -> dict[str, str]: "Meta is rate limiting requests for this connection, so this sync run did not finish. " "The next sync runs on schedule." ), + ENTITY_PAGE_REFUSED_ERROR_MESSAGE: ( + "Meta was too busy to return this table's data, so this sync run did not finish. " + "This usually clears on its own and the next sync runs on schedule." + ), } def get_schemas( diff --git a/products/warehouse_sources/backend/temporal/data_imports/sources/meta_ads/test_meta_ads.py b/products/warehouse_sources/backend/temporal/data_imports/sources/meta_ads/test_meta_ads.py index fdcf5000daad..34104ca96d70 100644 --- a/products/warehouse_sources/backend/temporal/data_imports/sources/meta_ads/test_meta_ads.py +++ b/products/warehouse_sources/backend/temporal/data_imports/sources/meta_ads/test_meta_ads.py @@ -13,7 +13,10 @@ JSONDecodeError as RequestsJSONDecodeError, ) -from products.warehouse_sources.backend.temporal.data_imports.sources.common.base import VersionDeprecation +from products.warehouse_sources.backend.temporal.data_imports.sources.common.base import ( + VersionDeprecation, + error_message_matches, +) from products.warehouse_sources.backend.temporal.data_imports.sources.common.integration_accounts import ( IntegrationAccountListingError, ) @@ -25,6 +28,7 @@ from products.warehouse_sources.backend.temporal.data_imports.sources.meta_ads import meta_ads as meta_ads_module from products.warehouse_sources.backend.temporal.data_imports.sources.meta_ads.meta_ads import ( AD_ACCOUNT_LISTING_TIMEOUT_SECONDS, + ENTITY_PAGE_REFUSED_ERROR_MESSAGE, MALFORMED_JSON_MAX_ATTEMPTS, MAX_AD_ACCOUNT_PAGES, META_ADS_API_VERSION_V25, @@ -36,6 +40,7 @@ META_TRANSIENT_ERROR_MAX_ATTEMPTS, PAGE_LIMIT_FALLBACK_SIZES, SHRINK_EXHAUSTED_ERROR_MESSAGE, + SMALLEST_PAGE_LIMIT_MAX_RETRIES, MetaAdsAuthError, MetaAdsResumeConfig, _earliest_supported_since, @@ -329,21 +334,49 @@ def test_cursor_too_much_data_retries_with_smaller_limit(self) -> None: == "https://graph.facebook.com/v20/next?after=p1&limit=100" ) - def test_exhausting_limit_ladder_raises(self) -> None: + def test_exhausting_limit_ladder_raises_retryable_error(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(meta_ads_module, "_backoff_sleep", lambda attempt: None) manager = _build_manager() - # Every rung in PAGE_LIMIT_FALLBACK_SIZES returns the too-much-data error. - responses = [_mock_response(500, self.REDUCE_BODY) for _ in PAGE_LIMIT_FALLBACK_SIZES] + attempts = len(PAGE_LIMIT_FALLBACK_SIZES) + SMALLEST_PAGE_LIMIT_MAX_RETRIES + responses = [] + for _ in range(attempts): + response = _mock_response(500, self.REDUCE_BODY) + # The real body carries Meta's "reduce the amount of data" text, which is a + # non-retryable pattern, so the raised error must not echo it. + response.text = json.dumps(self.REDUCE_BODY) + responses.append(response) with mock.patch( "products.warehouse_sources.backend.temporal.data_imports.sources.meta_ads.meta_ads.make_tracked_session" ) as mock_get: mock_get.return_value.get.side_effect = responses - # Terminal: the next attempt would re-issue the same request. - with pytest.raises(Exception, match=SHRINK_EXHAUSTED_ERROR_MESSAGE): + with pytest.raises(Exception) as exc_info: list(_iter_simple_pagination(self.INITIAL_URL, self.PARAMS, None, manager)) - # One attempt per rung, then it gives up. - assert mock_get.return_value.get.call_count == len(PAGE_LIMIT_FALLBACK_SIZES) + assert mock_get.return_value.get.call_count == attempts + error_message = str(exc_info.value) + assert ENTITY_PAGE_REFUSED_ERROR_MESSAGE in error_message + source = MetaAdsSource() + assert not error_message_matches(error_message, source.get_non_retryable_errors()) + assert error_message_matches(error_message, source.get_retryable_errors()) + + def test_smallest_limit_recovers_after_backoff(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(meta_ads_module, "_backoff_sleep", lambda attempt: None) + manager = _build_manager() + refusals = len(PAGE_LIMIT_FALLBACK_SIZES) + SMALLEST_PAGE_LIMIT_MAX_RETRIES - 1 + responses = [ + *(_mock_response(500, self.REDUCE_BODY) for _ in range(refusals)), + _mock_response(200, {"data": [{"id": "1"}], "paging": {}}), + ] + + with mock.patch( + "products.warehouse_sources.backend.temporal.data_imports.sources.meta_ads.meta_ads.make_tracked_session" + ) as mock_get: + mock_get.return_value.get.side_effect = responses + batches = list(_iter_simple_pagination(self.INITIAL_URL, self.PARAMS, None, manager)) + + assert batches == [[{"id": "1"}]] + assert mock_get.return_value.get.call_args_list[-1].kwargs["params"]["limit"] == PAGE_LIMIT_FALLBACK_SIZES[-1] def test_non_timeout_error_does_not_retry(self) -> None: manager = _build_manager() @@ -1591,6 +1624,11 @@ def test_empty_body_500_matches_retryable_pattern(self) -> None: '"fbtrace_id":"AaBbCcDdEeFf00112233"}})', "rate limiting", ), + ( + f"{ENTITY_PAGE_REFUSED_ERROR_MESSAGE} (Meta API response: 500, code 1, subcode None, " + "fbtrace_id AaBbCcDdEeFf00112233)", + "too busy", + ), ], ) def test_retry_exhausted_message_replaces_the_raw_meta_response( From fcf0b83ab6c038afb4a6af3bd395b26124db7506 Mon Sep 17 00:00:00 2001 From: jovan sakovic Date: Fri, 2 Oct 2026 15:37:28 +0100 Subject: [PATCH 29/48] chore(data-warehouse): remove data quality tab from data ops The same overview lives on the models scene's data quality tab. Co-Authored-By: Claude Opus 5.5 --- .../frontend/scenes/DataOpsScene/DataWarehouseScene.tsx | 5 ----- .../frontend/scenes/DataOpsScene/dataWarehouseSceneLogic.ts | 4 ---- 2 files changed, 9 deletions(-) diff --git a/products/data_warehouse/frontend/scenes/DataOpsScene/DataWarehouseScene.tsx b/products/data_warehouse/frontend/scenes/DataOpsScene/DataWarehouseScene.tsx index 09268ff869e6..3d3fdc130639 100644 --- a/products/data_warehouse/frontend/scenes/DataOpsScene/DataWarehouseScene.tsx +++ b/products/data_warehouse/frontend/scenes/DataOpsScene/DataWarehouseScene.tsx @@ -12,8 +12,6 @@ import { SceneContent } from '~/layout/scenes/components/SceneContent' import { SceneTitleSection } from '~/layout/scenes/components/SceneTitleSection' import { ProductKey } from '~/queries/schema/schema-general' -import { DataQualityOverview } from 'products/data_quality/frontend/overview/DataQualityOverview' - import { DataWarehouseTab, dataWarehouseSceneLogic } from './dataWarehouseSceneLogic' import { MonitoringTab } from './tabs/MonitoringTab' import { OverviewTab } from './tabs/OverviewTab' @@ -29,7 +27,6 @@ const TAB_LABELS: Record = { [DataWarehouseTab.OVERVIEW]: 'Overview', [DataWarehouseTab.MONITORING]: 'Monitoring', [DataWarehouseTab.SETTINGS]: 'Settings', - [DataWarehouseTab.DATA_QUALITY]: 'Data quality', } function tabContent(tab: DataWarehouseTab): JSX.Element { @@ -40,8 +37,6 @@ function tabContent(tab: DataWarehouseTab): JSX.Element { return case DataWarehouseTab.SETTINGS: return - case DataWarehouseTab.DATA_QUALITY: - return } } diff --git a/products/data_warehouse/frontend/scenes/DataOpsScene/dataWarehouseSceneLogic.ts b/products/data_warehouse/frontend/scenes/DataOpsScene/dataWarehouseSceneLogic.ts index 562af57d5232..fd7d93728646 100644 --- a/products/data_warehouse/frontend/scenes/DataOpsScene/dataWarehouseSceneLogic.ts +++ b/products/data_warehouse/frontend/scenes/DataOpsScene/dataWarehouseSceneLogic.ts @@ -15,7 +15,6 @@ export enum DataWarehouseTab { OVERVIEW = 'overview', MONITORING = 'monitoring', SETTINGS = 'settings', - DATA_QUALITY = 'data-quality', } function isDataWarehouseTab(tab: unknown): tab is DataWarehouseTab { @@ -122,9 +121,6 @@ export const dataWarehouseSceneLogic = kea([ tabs.push(DataWarehouseTab.OVERVIEW) tabs.push(DataWarehouseTab.MONITORING) } - if (featureFlags[FEATURE_FLAGS.DATA_QUALITY_CHECKS]) { - tabs.push(DataWarehouseTab.DATA_QUALITY) - } if (featureFlags[FEATURE_FLAGS.DATA_WAREHOUSE_SCENE]) { tabs.push(DataWarehouseTab.SETTINGS) } From 250501457189e657c309dc6ef87e641c30ade7e0 Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Fri, 2 Oct 2026 16:37:45 +0200 Subject: [PATCH 30/48] fix(sql-editor): polish bi worksheet interactions --- .../queries/nodes/DataNode/dataNodeLogic.ts | 1 + .../data-warehouse/editor/bi/BIEditor.tsx | 4 +- .../data-warehouse/editor/bi/biEditorLogic.ts | 63 ++++++++++--- .../data-warehouse/editor/bi/biEditorTypes.ts | 8 +- .../editor/bi/components/BIDataPane.tsx | 17 +++- .../editor/bi/components/BIFieldPill.tsx | 2 +- .../editor/bi/components/BIPill.tsx | 7 +- .../bi/components/BIShelfDropTarget.tsx | 5 +- .../editor/bi/components/BIShelfStrip.tsx | 8 +- .../editor/sqlEditorLogic.test.ts | 88 ++++++++++++++++++- .../data-warehouse/editor/sqlEditorLogic.tsx | 36 ++++++-- 11 files changed, 204 insertions(+), 35 deletions(-) diff --git a/frontend/src/queries/nodes/DataNode/dataNodeLogic.ts b/frontend/src/queries/nodes/DataNode/dataNodeLogic.ts index b8033de9b195..9777f848ac26 100644 --- a/frontend/src/queries/nodes/DataNode/dataNodeLogic.ts +++ b/frontend/src/queries/nodes/DataNode/dataNodeLogic.ts @@ -1341,6 +1341,7 @@ export const dataNodeLogic = kea([ loadNewData: () => false, loadData: () => false, cancelQuery: () => true, + clearResponse: () => false, }, ], pollResponse: [ diff --git a/frontend/src/scenes/data-warehouse/editor/bi/BIEditor.tsx b/frontend/src/scenes/data-warehouse/editor/bi/BIEditor.tsx index 77610fc06a81..858c0c898853 100644 --- a/frontend/src/scenes/data-warehouse/editor/bi/BIEditor.tsx +++ b/frontend/src/scenes/data-warehouse/editor/bi/BIEditor.tsx @@ -20,7 +20,7 @@ import { BIToolbar } from './components/BIToolbar' * shelves above the view, and a chart picker on the right. */ export function BIEditor({ tabId, children }: { tabId: string; children: ReactNode }): JSX.Element { - const { config, showMeOpen } = useValues(biEditorLogic({ tabId })) + const { config, showMeOpen, dragSessionId } = useValues(biEditorLogic({ tabId })) const { removeFieldFromShelf, setActiveDropShelf } = useActions(biEditorLogic({ tabId })) const { biSidePaneWidth, biEditorResizerProps } = useValues(editorSizingLogic) @@ -35,7 +35,7 @@ export function BIEditor({ tabId, children }: { tabId: string; children: ReactNo }} onDrop={(event) => { const pill = parseBIShelfPillDragData(event.dataTransfer.getData(BI_SHELF_PILL_DRAG_MIME_TYPE)) - if (pill) { + if (pill?.dragSessionId === dragSessionId) { event.preventDefault() removeFieldFromShelf(pill.shelf, pill.index) } diff --git a/frontend/src/scenes/data-warehouse/editor/bi/biEditorLogic.ts b/frontend/src/scenes/data-warehouse/editor/bi/biEditorLogic.ts index 20624af0d2a5..d205336a3413 100644 --- a/frontend/src/scenes/data-warehouse/editor/bi/biEditorLogic.ts +++ b/frontend/src/scenes/data-warehouse/editor/bi/biEditorLogic.ts @@ -160,27 +160,26 @@ function removeFieldFromConfig(config: BIConfig, shelf: BIShelf, index: number): } function setFieldExpressionInConfig(config: BIConfig, shelf: BIShelf, index: number, expression: string): BIConfig { + const updateField = (field: BIField): BIField => ({ ...field, expression, name: expression.trim() }) switch (shelf) { case 'rows': case 'columns': return { ...config, - [shelf]: config[shelf].map((field, fieldIndex) => - fieldIndex === index ? { ...field, expression } : field - ), + [shelf]: config[shelf].map((field, fieldIndex) => (fieldIndex === index ? updateField(field) : field)), } case 'values': return { ...config, values: config.values.map((value, valueIndex) => - valueIndex === index ? { ...value, field: { ...value.field, expression } } : value + valueIndex === index ? { ...value, field: updateField(value.field) } : value ), } case 'filters': return { ...config, filters: config.filters.map((filter, filterIndex) => - filterIndex === index ? { ...filter, field: { ...filter.field, expression } } : filter + filterIndex === index ? { ...filter, field: updateField(filter.field) } : filter ), } } @@ -234,8 +233,10 @@ export interface biEditorLogicValues { chartFits: Partial> config: BIConfig dataPaneFields: BIDataPaneFields + dataPaneFieldsError: boolean dataPaneFieldsLoading: boolean dataPaneSearch: string + dragSessionId: string editorView: BIEditorView filteredDataPaneFields: BIDataPaneFields generatedQuery: BIQueryBuildResult | null @@ -431,12 +432,17 @@ export interface biEditorLogicMeta { generatedQuery: (config: BIConfig) => BIQueryBuildResult | null sortOptions: (config: BIConfig) => BISortOption[] chartFits: (config: BIConfig) => Partial> - dataPaneFields: (config: BIConfig, allTables: DatabaseSchemaTable[]) => BIDataPaneFields + dataPaneFields: ( + config: BIConfig, + allTables: DatabaseSchemaTable[], + databaseConnectionId: string | null + ) => BIDataPaneFields dataPaneFieldsLoading: ( config: BIConfig, tableFieldsStatus: TableFieldsStatus, databaseLoading: boolean ) => boolean + dataPaneFieldsError: (config: BIConfig, tableFieldsStatus: TableFieldsStatus) => boolean filteredDataPaneFields: (dataPaneFields: BIDataPaneFields, dataPaneSearch: string) => BIDataPaneFields } } @@ -514,7 +520,8 @@ export const biEditorLogic = kea([ resetConfig: true, syncGeneratedQuery: true, }), - reducers({ + reducers(() => ({ + dragSessionId: [uuid(), {}], activeDropShelf: [ null as BIShelf | null, { @@ -626,7 +633,7 @@ export const biEditorLogic = kea([ resetConfig: freshBIConfig, }, ], - }), + })), selectors({ availableDataSources: [ (selectors) => [selectors.allTables, selectors.posthogTables, selectors.databaseConnectionId], @@ -674,9 +681,13 @@ export const biEditorLogic = kea([ ), ], dataPaneFields: [ - (selectors) => [selectors.config, selectors.allTables], - (config: BIConfig, allTables: DatabaseSchemaTable[]): BIDataPaneFields => - config.source + (selectors) => [selectors.config, selectors.allTables, selectors.databaseConnectionId], + ( + config: BIConfig, + allTables: DatabaseSchemaTable[], + databaseConnectionId: string | null + ): BIDataPaneFields => + config.source && (config.source.connectionId ?? null) === databaseConnectionId ? getBIDataPaneFields( allTables.find((table) => table.name === config.source?.table), config.source @@ -688,6 +699,11 @@ export const biEditorLogic = kea([ (config: BIConfig, tableFieldsStatus: TableFieldsStatus, databaseLoading: boolean): boolean => !!config.source && (databaseLoading || tableFieldsStatus[config.source.table] === 'loading'), ], + dataPaneFieldsError: [ + (selectors) => [selectors.config, selectors.tableFieldsStatus], + (config: BIConfig, tableFieldsStatus: TableFieldsStatus): boolean => + !!config.source && tableFieldsStatus[config.source.table] === 'error', + ], filteredDataPaneFields: [ (selectors) => [selectors.dataPaneFields, selectors.dataPaneSearch], (dataPaneFields: BIDataPaneFields, dataPaneSearch: string): BIDataPaneFields => { @@ -724,6 +740,11 @@ export const biEditorLogic = kea([ captureBIEditorModeSelected(editorView, values.config) actions.persistState(editorView, values.config) if (editorView === BIEditorView.BI) { + const connectionId = sqlEditorLogic({ tabId: logicProps.tabId }).values.selectedConnectionId + if (values.config.source && (values.config.source.connectionId ?? null) !== (connectionId ?? null)) { + actions.resetConfig() + return + } actions.syncGeneratedQuery() } }, @@ -753,7 +774,16 @@ export const biEditorLogic = kea([ ) { return } - sqlEditorLogic({ tabId: logicProps.tabId }).actions.runQuery() + const editorLogic = sqlEditorLogic({ tabId: logicProps.tabId }) + const lastSource = editorLogic.values.lastRunQuery?.source + const nextSource = values.generatedQuery.node.source + if ( + lastSource?.query === nextSource.query && + (lastSource.connectionId ?? null) === (nextSource.connectionId ?? null) + ) { + return + } + editorLogic.actions.runQuery() }, addBlankFieldToShelf: () => actions.runAfterChange(), removeFieldFromShelf: () => actions.runAfterChange(), @@ -772,6 +802,14 @@ export const biEditorLogic = kea([ if (!values.activeTab?.biEditorState && values.editorView === BIEditorView.SQL) { return } + if ( + values.editorView === BIEditorView.BI && + values.config.source && + (values.config.source.connectionId ?? null) !== (sourceQuery.source.connectionId ?? null) + ) { + actions.resetConfig() + return + } actions.persistState(values.editorView, { ...values.config, chartType: sourceQuery.display ?? values.config.chartType, @@ -785,6 +823,7 @@ export const biEditorLogic = kea([ dataLogic?.actions.clearResponse() const editorLogic = sqlEditorLogic({ tabId: logicProps.tabId }) const sourceQuery = editorLogic.values.sourceQuery + editorLogic.actions.setLastRunQuery(null) editorLogic.actions.setQueryInput('') editorLogic.actions.setSourceQuery({ ...sourceQuery, diff --git a/frontend/src/scenes/data-warehouse/editor/bi/biEditorTypes.ts b/frontend/src/scenes/data-warehouse/editor/bi/biEditorTypes.ts index b583a0cb834f..e718db82de80 100644 --- a/frontend/src/scenes/data-warehouse/editor/bi/biEditorTypes.ts +++ b/frontend/src/scenes/data-warehouse/editor/bi/biEditorTypes.ts @@ -213,6 +213,7 @@ export const BI_SHELF_PILL_DRAG_MIME_TYPE = 'application/x-posthog-bi-shelf-pill export interface BIShelfPillDragData { shelf: BIShelf index: number + dragSessionId: string } export function parseBIShelfPillDragData(serialized: string): BIShelfPillDragData | null { @@ -220,9 +221,12 @@ export function parseBIShelfPillDragData(serialized: string): BIShelfPillDragDat const candidate = JSON.parse(serialized) as Partial if ( ['rows', 'columns', 'values', 'filters'].includes(candidate.shelf as string) && - typeof candidate.index === 'number' + typeof candidate.index === 'number' && + Number.isInteger(candidate.index) && + candidate.index >= 0 && + typeof candidate.dragSessionId === 'string' ) { - return { shelf: candidate.shelf as BIShelf, index: candidate.index } + return { shelf: candidate.shelf as BIShelf, index: candidate.index, dragSessionId: candidate.dragSessionId } } } catch { return null diff --git a/frontend/src/scenes/data-warehouse/editor/bi/components/BIDataPane.tsx b/frontend/src/scenes/data-warehouse/editor/bi/components/BIDataPane.tsx index 0b6556c822e8..7dcb334ff788 100644 --- a/frontend/src/scenes/data-warehouse/editor/bi/components/BIDataPane.tsx +++ b/frontend/src/scenes/data-warehouse/editor/bi/components/BIDataPane.tsx @@ -101,11 +101,12 @@ export function BIDataPane(): JSX.Element { databaseLoading, dataPaneFields, dataPaneFieldsLoading, + dataPaneFieldsError, dataPaneSearch, filteredDataPaneFields, selectableDataSources, } = useValues(biEditorLogic) - const { setDataPaneSearch, setDataSource } = useActions(biEditorLogic) + const { hydrateTableFields, setDataPaneSearch, setDataSource } = useActions(biEditorLogic) const { setDatabaseTreeCollapsed } = useActions(editorSizingLogic) const { locateTable } = useActions(queryDatabaseLogic) @@ -183,6 +184,18 @@ export function BIDataPane(): JSX.Element {
Loading fields
+ ) : dataPaneFieldsError ? ( +
+ Couldn't load fields for this table. + config.source && hydrateTableFields([config.source.table])} + > + Retry + +
) : !hasFields ? (

No fields found. Drag columns from the database tree instead. @@ -204,7 +217,7 @@ export function BIDataPane(): JSX.Element { emptyText={ dataPaneSearch ? 'No matching measures' - : 'No numeric fields. Use a dimension menu to count its values.' + : 'No measures listed. Add a dimension to Rows, then choose Convert to measure from its menu.' } /> diff --git a/frontend/src/scenes/data-warehouse/editor/bi/components/BIFieldPill.tsx b/frontend/src/scenes/data-warehouse/editor/bi/components/BIFieldPill.tsx index 13aa9f5aa88e..68374e28ef1a 100644 --- a/frontend/src/scenes/data-warehouse/editor/bi/components/BIFieldPill.tsx +++ b/frontend/src/scenes/data-warehouse/editor/bi/components/BIFieldPill.tsx @@ -147,7 +147,7 @@ export function BIFieldPill({ } }} > - + (function BIPill { kind, label, detail, shelf, index, incomplete, className, ...buttonProps }, ref ) { + const { dragSessionId } = useValues(biEditorLogic) return ( + + + ) : ( + <> + } className="mb-5 flex flex-col"> + Start a new session + + in + {resolving ? ( + + ) : ( + + )} + space + + + {resolving ? ( + COMPOSER_SKELETON + ) : ( + // Each space gets its own composer, so it starts on that space's repository. + + )} + + )} +

+ + ) +} diff --git a/products/tasks/frontend/spaces/NewSessionSpaceSelect.tsx b/products/tasks/frontend/spaces/NewSessionSpaceSelect.tsx new file mode 100644 index 000000000000..337d233d505d --- /dev/null +++ b/products/tasks/frontend/spaces/NewSessionSpaceSelect.tsx @@ -0,0 +1,93 @@ +import { useMemo, useRef } from 'react' + +import { IconChevronDown } from '@posthog/icons' +import { + Combobox, + ComboboxCollection, + ComboboxContent, + ComboboxEmpty, + ComboboxGroup, + ComboboxInput, + ComboboxItem, + ComboboxLabel, + ComboboxList, + ComboboxSeparator, + ComboboxTrigger, +} from '@posthog/quill' + +import { TodaySpaceGlyph } from '~/layout/today/TodaySpaceGlyph' +import { isLockedSpace, spaceLabel } from '~/layout/today/todaySpacesLogic' + +import { ChannelDTOApi } from '../generated/api.schemas' +import { NewSessionSpaceGroup } from './newSessionSceneLogic' + +export interface NewSessionSpaceSelectProps { + spaces: ChannelDTOApi[] + groups: NewSessionSpaceGroup[] + value: ChannelDTOApi | null + onChange: (spaceId: string) => void +} + +/** The space a new session files into, drawn inside the page heading like PostHog Desktop. */ +export function NewSessionSpaceSelect({ spaces, groups, value, onChange }: NewSessionSpaceSelectProps): JSX.Element { + const anchorRef = useRef(null) + const byId = useMemo(() => new Map(spaces.map((space) => [space.id, space])), [spaces]) + + return ( + + items={groups} + value={value?.id ?? null} + onValueChange={(spaceId: string | null) => { + if (spaceId && spaceId !== value?.id) { + onChange(spaceId) + } + }} + itemToStringLabel={(spaceId: string) => { + const space = byId.get(spaceId) + return space ? spaceLabel(space) : '' + }} + > + + {value ? spaceLabel(value) : 'choose a space'} + + + } + /> + + + No spaces match that name. + + {(group: NewSessionSpaceGroup, index: number) => ( + + {index > 0 && } + {group.value} + + {(spaceId: string) => { + const space = byId.get(spaceId) + return space ? ( + + + {spaceLabel(space)} + + ) : null + }} + + + )} + + + + ) +} diff --git a/products/tasks/frontend/spaces/SpaceScene.tsx b/products/tasks/frontend/spaces/SpaceScene.tsx index 696b03cc394b..a9b3de2fb890 100644 --- a/products/tasks/frontend/spaces/SpaceScene.tsx +++ b/products/tasks/frontend/spaces/SpaceScene.tsx @@ -33,16 +33,15 @@ import { EmbeddedTaskComposer } from 'products/posthog_ai/frontend/api/runner' import { SpaceCanvases } from './SpaceCanvases' import { SpaceFeed } from './SpaceFeed' -import { SpaceSceneLogicProps, SpaceTab, spaceComposerPanelId, spaceSceneLogic } from './spaceSceneLogic' +import { + SPACE_COMPOSER_OVERRIDE, + SpaceSceneLogicProps, + SpaceTab, + spaceComposerPanelId, + spaceSceneLogic, +} from './spaceSceneLogic' import { SpaceSettings } from './SpaceSettings' -const SPACE_COMPOSER_OVERRIDE = { - placeholder: 'What do you want to ship?', - hideSuggestions: true, - hideRecentTasks: true, - hideOnboardingReplay: true, -} - // The repository picker and the input frame at their loaded sizes, so the feed does not jump when the chunk lands. const COMPOSER_SKELETON = (
diff --git a/products/tasks/frontend/spaces/newSessionSceneLogic.ts b/products/tasks/frontend/spaces/newSessionSceneLogic.ts new file mode 100644 index 000000000000..6ad3a1125ce1 --- /dev/null +++ b/products/tasks/frontend/spaces/newSessionSceneLogic.ts @@ -0,0 +1,126 @@ +import { MakeLogicType, actions, connect, kea, listeners, path, reducers, selectors } from 'kea' +import { router } from 'kea-router' +import posthog from 'posthog-js' + +import { Scene } from 'scenes/sceneTypes' +import { urls } from 'scenes/urls' + +import { starredSpaces, todaySpacesLogic } from '~/layout/today/todaySpacesLogic' +import { Breadcrumb } from '~/types' + +import { ChannelDTOApi } from '../generated/api.schemas' +import { SpaceComposerRepositoryConfig, spaceComposerRepositoryConfig } from './spaceSceneLogic' + +/** The `{ value, items }` shape is what the combobox reads as a group. */ +export interface NewSessionSpaceGroup { + value: string + items: string[] +} + +/** Like PostHog Desktop: a new session files into the personal space until the user picks another one. */ +export function newSessionSpace(spaces: ChannelDTOApi[], pickedSpaceId: string | null): ChannelDTOApi | null { + return ( + spaces.find((space) => space.id === pickedSpaceId) ?? + spaces.find((space) => space.system_role === 'personal') ?? + spaces[0] ?? + null + ) +} + +/** The personal and starred spaces first, then the rest, under the same headings as the Spaces page. */ +export function newSessionSpaceGroups(spaces: ChannelDTOApi[]): NewSessionSpaceGroup[] { + const starredIds = starredSpaces(spaces).map((space) => space.id) + const restIds = spaces.filter((space) => !starredIds.includes(space.id)).map((space) => space.id) + return [ + { value: 'Starred', items: starredIds }, + { value: 'Spaces', items: restIds }, + ].filter((group) => group.items.length > 0) +} + +// Generated by kea-typegen. Update if you're an agent, ignore if you're human. +export interface newSessionSceneLogicValues { + sortedSpaces: ChannelDTOApi[] // todaySpacesLogic + spacesLoading: boolean // todaySpacesLogic + spacesUnavailable: boolean // todaySpacesLogic + breadcrumbs: Breadcrumb[] + composerRepositoryConfig: SpaceComposerRepositoryConfig + pickedSpaceId: string | null + space: ChannelDTOApi | null + spaceGroups: NewSessionSpaceGroup[] +} + +// Generated by kea-typegen. Update if you're an agent, ignore if you're human. +export interface newSessionSceneLogicActions { + loadRecentTasks: () => any // todaySpacesLogic + loadSpaces: () => any // todaySpacesLogic + pickSpace: (spaceId: string) => { + spaceId: string + } + sessionStarted: (sessionId: string) => { + sessionId: string + } +} + +// Generated by kea-typegen. Update if you're an agent, ignore if you're human. +export interface newSessionSceneLogicMeta { + __keaTypeGenInternalSelectorTypes: { + space: (sortedSpaces: ChannelDTOApi[], pickedSpaceId: string | null) => ChannelDTOApi | null + spaceGroups: (sortedSpaces: ChannelDTOApi[]) => NewSessionSpaceGroup[] + composerRepositoryConfig: (space: any) => SpaceComposerRepositoryConfig + } +} + +export type newSessionSceneLogicType = MakeLogicType< + newSessionSceneLogicValues, + newSessionSceneLogicActions, + Record, + newSessionSceneLogicMeta +> + +export const newSessionSceneLogic = kea([ + path(['products', 'tasks', 'spaces', 'newSessionSceneLogic']), + connect(() => ({ + values: [todaySpacesLogic, ['sortedSpaces', 'spacesLoading', 'spacesUnavailable']], + actions: [todaySpacesLogic, ['loadSpaces', 'loadRecentTasks']], + })), + actions({ + pickSpace: (spaceId: string) => ({ spaceId }), + sessionStarted: (sessionId: string) => ({ sessionId }), + }), + reducers({ + pickedSpaceId: [null as string | null, { pickSpace: (_, { spaceId }) => spaceId }], + }), + selectors({ + space: [ + (s) => [s.sortedSpaces, s.pickedSpaceId], + (sortedSpaces: ChannelDTOApi[], pickedSpaceId: string | null): ChannelDTOApi | null => + newSessionSpace(sortedSpaces, pickedSpaceId), + ], + spaceGroups: [ + (s) => [s.sortedSpaces], + (sortedSpaces: ChannelDTOApi[]): NewSessionSpaceGroup[] => newSessionSpaceGroups(sortedSpaces), + ], + composerRepositoryConfig: [ + (s) => [s.space], + (space: ChannelDTOApi | null): SpaceComposerRepositoryConfig => spaceComposerRepositoryConfig(space), + ], + breadcrumbs: [ + () => [], + (): Breadcrumb[] => [ + { key: Scene.TaskSpaces, name: 'Spaces', path: urls.taskSpaces(), iconType: 'task' }, + { key: Scene.TaskNewSession, name: 'New session', path: urls.taskNewSession(), iconType: 'task' }, + ], + ], + }), + listeners(({ actions, values }) => ({ + sessionStarted: ({ sessionId }) => { + // pinned: analytics event name and properties. Renaming them breaks dashboards. + posthog.capture('today new session started', { + space_role: values.space?.system_role ?? null, + space_picked: values.pickedSpaceId !== null, + }) + actions.loadRecentTasks() + router.actions.push(urls.aiTask(sessionId)) + }, + })), +]) diff --git a/products/tasks/frontend/spaces/spaceSceneLogic.ts b/products/tasks/frontend/spaces/spaceSceneLogic.ts index 4195afb00e78..3c758e6eabbe 100644 --- a/products/tasks/frontend/spaces/spaceSceneLogic.ts +++ b/products/tasks/frontend/spaces/spaceSceneLogic.ts @@ -100,6 +100,20 @@ export function spaceComposerPanelId(spaceId: string): string { return `space-${spaceId}` } +export const SPACE_COMPOSER_OVERRIDE: EmbeddedTaskComposerProps['composerOverride'] = { + placeholder: 'What do you want to ship?', + hideSuggestions: true, + hideRecentTasks: true, + hideOnboardingReplay: true, +} + +/** A new session starts on the space's first repository. */ +export function spaceComposerRepositoryConfig(space: ChannelDTOApi | null): SpaceComposerRepositoryConfig { + return space?.repositories.length + ? { integrationId: space.github_integration ?? undefined, repository: space.repositories[0] } + : undefined +} + // Generated by kea-typegen. Update if you're an agent, ignore if you're human. export interface spaceSceneLogicValues { filters: SpaceFeedFilters // spaceFeedViewLogic @@ -848,13 +862,9 @@ export const spaceSceneLogic = kea([ }, ], ], - // The new-session composer starts on the space's first repository. composerRepositoryConfig: [ (s) => [s.space], - (space: ChannelDTOApi | null): SpaceComposerRepositoryConfig => - space?.repositories.length - ? { integrationId: space.github_integration ?? undefined, repository: space.repositories[0] } - : undefined, + (space: ChannelDTOApi | null): SpaceComposerRepositoryConfig => spaceComposerRepositoryConfig(space), ], }), listeners(({ actions, props, values }) => ({ diff --git a/products/tasks/manifest.tsx b/products/tasks/manifest.tsx index c6ef0c813a86..60a82dc41fb5 100644 --- a/products/tasks/manifest.tsx +++ b/products/tasks/manifest.tsx @@ -14,6 +14,11 @@ export const manifest: ProductManifest = { import: () => import('./frontend/spaces/SpacesScene'), projectBased: true, }, + TaskNewSession: { + name: 'New session', + import: () => import('./frontend/spaces/NewSessionScene'), + projectBased: true, + }, TaskSpace: { name: 'Space', import: () => import('./frontend/spaces/SpaceScene'), @@ -23,6 +28,8 @@ export const manifest: ProductManifest = { routes: { '/slack-task-context': ['SlackTaskContext', 'slackTaskContext'], '/spaces': ['TaskSpaces', 'taskSpaces'], + // Before `/spaces/:id`, so the router does not read `new` as a space id. + '/spaces/new': ['TaskNewSession', 'taskNewSession'], '/spaces/:id': ['TaskSpace', 'taskSpace'], '/spaces/:id/canvases': ['TaskSpace', 'taskSpaceCanvases'], '/spaces/:id/settings': ['TaskSpace', 'taskSpaceSettings'], @@ -31,6 +38,7 @@ export const manifest: ProductManifest = { urls: { slackTaskContext: (): string => '/slack-task-context', taskSpaces: (): string => '/spaces', + taskNewSession: (): string => '/spaces/new', taskSpace: (id: string): string => `/spaces/${id}`, taskSpaceCanvases: (id: string): string => `/spaces/${id}/canvases`, taskSpaceSettings: (id: string): string => `/spaces/${id}/settings`, diff --git a/products/tasks/package.json b/products/tasks/package.json index 4faf9f851e99..a369ae3ade43 100644 --- a/products/tasks/package.json +++ b/products/tasks/package.json @@ -6,7 +6,8 @@ "dependencies": { "@posthog/products-mcp-store": "workspace:*", "@posthog/quill": "workspace:*", - "kea-disposables": "catalog:" + "kea-disposables": "catalog:", + "posthog-js": "catalog:" }, "devDependencies": { "kea-test-utils": "catalog:", From 9fca0c990961b0dfd431e74f768e7773a9be428e Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Fri, 2 Oct 2026 15:39:22 +0100 Subject: [PATCH 32/48] fix(tasks): rename new chat to new session and share the space composer The Today rail button and its empty-state hint now say New session. The new session page and the space Activity tab render the same SpaceTaskComposer component. The data-attr values stay the same. Generated-By: PostHog Desktop Task-Id: cf2d5082-cc69-4bf1-8ac5-d16302185ff0 --- .../src/layout/today/TodaySpacesSidebar.tsx | 4 +- .../project-homepage/today/Today.stories.tsx | 2 +- .../today/TodayHomeSidebar.tsx | 2 +- .../tasks/frontend/spaces/NewSessionScene.tsx | 36 ++++++---------- products/tasks/frontend/spaces/SpaceScene.tsx | 30 +++---------- .../frontend/spaces/SpaceTaskComposer.tsx | 43 +++++++++++++++++++ .../spaces/SpaceTaskComposerSkeleton.tsx | 20 +++++++++ .../tasks/frontend/spaces/spaceSceneLogic.ts | 7 --- 8 files changed, 86 insertions(+), 58 deletions(-) create mode 100644 products/tasks/frontend/spaces/SpaceTaskComposer.tsx create mode 100644 products/tasks/frontend/spaces/SpaceTaskComposerSkeleton.tsx diff --git a/frontend/src/layout/today/TodaySpacesSidebar.tsx b/frontend/src/layout/today/TodaySpacesSidebar.tsx index a7b617e48665..eb268a24d277 100644 --- a/frontend/src/layout/today/TodaySpacesSidebar.tsx +++ b/frontend/src/layout/today/TodaySpacesSidebar.tsx @@ -167,7 +167,7 @@ export function TodaySpacesSidebar(): JSX.Element { data-attr="today-spaces-new-chat" > - New chat + New session
{hasPinned && ( @@ -220,7 +220,7 @@ export function TodaySpacesSidebar(): JSX.Element { loadError('Recent sessions didn’t load.', loadRecentTasks, 'today-recent-retry') ) : recentState === 'empty' ? ( - Sessions and chats you open show up here. Start one with New chat. + Sessions and chats you open show up here. Start one with New session. ) : recentState === 'no-matches' ? ( notice( diff --git a/frontend/src/scenes/project-homepage/today/Today.stories.tsx b/frontend/src/scenes/project-homepage/today/Today.stories.tsx index 686109727a69..97e99946b4e6 100644 --- a/frontend/src/scenes/project-homepage/today/Today.stories.tsx +++ b/frontend/src/scenes/project-homepage/today/Today.stories.tsx @@ -778,7 +778,7 @@ export const SpacePage: Story = { parameters: { pageUrl: urls.taskSpace('space-checkout') }, } -// New chat opens this page. It files into the personal space until the user picks another one. +// New session opens this page. It files into the personal space until the user picks another one. export const NewSessionPage: Story = { parameters: { pageUrl: urls.taskNewSession() }, } diff --git a/frontend/src/scenes/project-homepage/today/TodayHomeSidebar.tsx b/frontend/src/scenes/project-homepage/today/TodayHomeSidebar.tsx index 6697bcc59330..c4f476d3807c 100644 --- a/frontend/src/scenes/project-homepage/today/TodayHomeSidebar.tsx +++ b/frontend/src/scenes/project-homepage/today/TodayHomeSidebar.tsx @@ -92,7 +92,7 @@ export function TodayHomeSidebar(): JSX.Element { data-attr="today-new-chat" > - New chat + New session
Today
diff --git a/products/tasks/frontend/spaces/NewSessionScene.tsx b/products/tasks/frontend/spaces/NewSessionScene.tsx index 43c8e18373df..6dce2d88dd9f 100644 --- a/products/tasks/frontend/spaces/NewSessionScene.tsx +++ b/products/tasks/frontend/spaces/NewSessionScene.tsx @@ -17,19 +17,10 @@ import { SceneExport } from 'scenes/sceneTypes' import { SceneContent } from '~/layout/scenes/components/SceneContent' -import { EmbeddedTaskComposer } from 'products/posthog_ai/frontend/api/runner' - import { newSessionSceneLogic } from './newSessionSceneLogic' import { NewSessionSpaceSelect } from './NewSessionSpaceSelect' -import { SPACE_COMPOSER_OVERRIDE } from './spaceSceneLogic' - -// The repository picker and the input frame at their loaded sizes, so the page does not jump when the chunk lands. -const COMPOSER_SKELETON = ( -
- - -
-) +import { SpaceTaskComposer } from './SpaceTaskComposer' +import { SpaceTaskComposerSkeleton } from './SpaceTaskComposerSkeleton' export const scene: SceneExport = { component: NewSessionScene, @@ -90,18 +81,19 @@ export function NewSessionScene(): JSX.Element { {resolving ? ( - COMPOSER_SKELETON + ) : ( - // Each space gets its own composer, so it starts on that space's repository. - + space && ( + // Each space gets its own composer, so it starts on that space's repository. +
+ +
+ ) )} )} diff --git a/products/tasks/frontend/spaces/SpaceScene.tsx b/products/tasks/frontend/spaces/SpaceScene.tsx index a9b3de2fb890..cd2edbc801ef 100644 --- a/products/tasks/frontend/spaces/SpaceScene.tsx +++ b/products/tasks/frontend/spaces/SpaceScene.tsx @@ -9,7 +9,6 @@ import { EmptyDescription, EmptyHeader, EmptyTitle, - Skeleton, Tabs, TabsContent, TabsList, @@ -29,26 +28,11 @@ import { SceneContent } from '~/layout/scenes/components/SceneContent' import { SceneTitleSection } from '~/layout/scenes/components/SceneTitleSection' import { spaceLabel } from '~/layout/today/todaySpacesLogic' -import { EmbeddedTaskComposer } from 'products/posthog_ai/frontend/api/runner' - import { SpaceCanvases } from './SpaceCanvases' import { SpaceFeed } from './SpaceFeed' -import { - SPACE_COMPOSER_OVERRIDE, - SpaceSceneLogicProps, - SpaceTab, - spaceComposerPanelId, - spaceSceneLogic, -} from './spaceSceneLogic' +import { SpaceSceneLogicProps, SpaceTab, spaceComposerPanelId, spaceSceneLogic } from './spaceSceneLogic' import { SpaceSettings } from './SpaceSettings' - -// The repository picker and the input frame at their loaded sizes, so the feed does not jump when the chunk lands. -const COMPOSER_SKELETON = ( -
- - -
-) +import { SpaceTaskComposer } from './SpaceTaskComposer' export const scene: SceneExport = { component: SpaceScene, @@ -164,16 +148,12 @@ export function SpaceScene({ id }: SpaceSceneLogicProps): JSX.Element { {/* Mounted once the space loads, so the composer starts on the space's repository. */} {space && (
-
)} diff --git a/products/tasks/frontend/spaces/SpaceTaskComposer.tsx b/products/tasks/frontend/spaces/SpaceTaskComposer.tsx new file mode 100644 index 000000000000..1a8db0b01df2 --- /dev/null +++ b/products/tasks/frontend/spaces/SpaceTaskComposer.tsx @@ -0,0 +1,43 @@ +import { EmbeddedTaskComposer } from 'products/posthog_ai/frontend/api/runner' + +import { ChannelDTOApi } from '../generated/api.schemas' +import { SpaceComposerRepositoryConfig } from './spaceSceneLogic' +import { SpaceTaskComposerSkeleton } from './SpaceTaskComposerSkeleton' + +const SPACE_COMPOSER_OVERRIDE = { + placeholder: 'What do you want to ship?', + hideSuggestions: true, + hideRecentTasks: true, + hideOnboardingReplay: true, +} + +export interface SpaceTaskComposerProps { + space: ChannelDTOApi + panelId: string + repositoryConfig: SpaceComposerRepositoryConfig + onTaskCreated: (sessionId: string) => void + focusRequest?: number +} + +/** The new-session composer that files into a space, on the space's activity tab and on the new session page. */ +export function SpaceTaskComposer({ + space, + panelId, + repositoryConfig, + onTaskCreated, + focusRequest, +}: SpaceTaskComposerProps): JSX.Element { + return ( + } + /> + ) +} diff --git a/products/tasks/frontend/spaces/SpaceTaskComposerSkeleton.tsx b/products/tasks/frontend/spaces/SpaceTaskComposerSkeleton.tsx new file mode 100644 index 000000000000..83644d7cec33 --- /dev/null +++ b/products/tasks/frontend/spaces/SpaceTaskComposerSkeleton.tsx @@ -0,0 +1,20 @@ +import { Skeleton } from '@posthog/quill' + +/** The repository picker, the input and the control row at their loaded sizes, so the page does not jump when the composer chunk lands. */ +export function SpaceTaskComposerSkeleton(): JSX.Element { + return ( +
+ +
+
+ +
+
+ + + +
+
+
+ ) +} diff --git a/products/tasks/frontend/spaces/spaceSceneLogic.ts b/products/tasks/frontend/spaces/spaceSceneLogic.ts index 3c758e6eabbe..7134ebf79514 100644 --- a/products/tasks/frontend/spaces/spaceSceneLogic.ts +++ b/products/tasks/frontend/spaces/spaceSceneLogic.ts @@ -100,13 +100,6 @@ export function spaceComposerPanelId(spaceId: string): string { return `space-${spaceId}` } -export const SPACE_COMPOSER_OVERRIDE: EmbeddedTaskComposerProps['composerOverride'] = { - placeholder: 'What do you want to ship?', - hideSuggestions: true, - hideRecentTasks: true, - hideOnboardingReplay: true, -} - /** A new session starts on the space's first repository. */ export function spaceComposerRepositoryConfig(space: ChannelDTOApi | null): SpaceComposerRepositoryConfig { return space?.repositories.length From 90a85b65831d32bf963a49363fea0a685a904ca1 Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Fri, 2 Oct 2026 15:39:26 +0100 Subject: [PATCH 33/48] chore(tasks): refresh new session logic typegen after rebase Generated-By: PostHog Desktop Task-Id: cf2d5082-cc69-4bf1-8ac5-d16302185ff0 --- products/tasks/frontend/spaces/newSessionSceneLogic.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/products/tasks/frontend/spaces/newSessionSceneLogic.ts b/products/tasks/frontend/spaces/newSessionSceneLogic.ts index 6ad3a1125ce1..67c7298c4620 100644 --- a/products/tasks/frontend/spaces/newSessionSceneLogic.ts +++ b/products/tasks/frontend/spaces/newSessionSceneLogic.ts @@ -66,7 +66,7 @@ export interface newSessionSceneLogicMeta { __keaTypeGenInternalSelectorTypes: { space: (sortedSpaces: ChannelDTOApi[], pickedSpaceId: string | null) => ChannelDTOApi | null spaceGroups: (sortedSpaces: ChannelDTOApi[]) => NewSessionSpaceGroup[] - composerRepositoryConfig: (space: any) => SpaceComposerRepositoryConfig + composerRepositoryConfig: (space: ChannelDTOApi | null) => SpaceComposerRepositoryConfig } } From bd9a99b6081f368664e5861e74d9e909f6f1e9a0 Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Fri, 2 Oct 2026 15:39:29 +0100 Subject: [PATCH 34/48] fix(tasks): place the new session composer 34% down the pane like desktop The heading and composer now sit with their middle 34% down the scene, in a 600px column, as on PostHog Desktop. Flex spacers do the placement, so the block stays below the top edge in a short window and the page does not scroll. Generated-By: PostHog Desktop Task-Id: cf2d5082-cc69-4bf1-8ac5-d16302185ff0 --- .../tasks/frontend/spaces/NewSessionScene.tsx | 116 +++++++++--------- 1 file changed, 60 insertions(+), 56 deletions(-) diff --git a/products/tasks/frontend/spaces/NewSessionScene.tsx b/products/tasks/frontend/spaces/NewSessionScene.tsx index 6dce2d88dd9f..1e9aa51ef418 100644 --- a/products/tasks/frontend/spaces/NewSessionScene.tsx +++ b/products/tasks/frontend/spaces/NewSessionScene.tsx @@ -41,62 +41,66 @@ export function NewSessionScene(): JSX.Element { const failed = !space && spacesUnavailable return ( - - {/* The same centered column as a space's feed, so the composer does not move between the two pages. */} -
- {failed ? ( - - - Your spaces didn’t load - Check your connection and try again. - - - - - - ) : ( - <> - } className="mb-5 flex flex-col"> - Start a new session - - in - {resolving ? ( - - ) : ( - - )} - space - - - {resolving ? ( - - ) : ( - space && ( - // Each space gets its own composer, so it starts on that space's repository. -
- -
- ) - )} - - )} + + {/* Like PostHog Desktop, the middle of the block sits 34% down the pane: the shrink weights take half the block's height from each spacer. */} +
+
+
+ {failed ? ( + + + Your spaces didn’t load + Check your connection and try again. + + + + + + ) : ( + <> + } className="mb-5 flex flex-col"> + Start a new session + + in + {resolving ? ( + + ) : ( + + )} + space + + + {resolving ? ( + + ) : ( + space && ( + // Each space gets its own composer, so it starts on that space's repository. +
+ +
+ ) + )} + + )} +
+
) From 02d00c0779b3209abf0742c8a1a10b4370a5fe71 Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Fri, 2 Oct 2026 15:39:32 +0100 Subject: [PATCH 35/48] fix(tasks): default new sessions to the right space and drop the default label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A space's New session opens /spaces/:id/new with that space chosen. The generic New session buttons use the last space the person was in, else their personal space, like PostHog Desktop's scoped space. A pick in the heading moves to that space's route. The quill model picker no longer shows the "Default ·" prefix, which Desktop does not show. The space page's ?compose=1 link had no callers left, so it is removed. Generated-By: PostHog Desktop Task-Id: cf2d5082-cc69-4bf1-8ac5-d16302185ff0 --- .../src/layout/today/TodaySpaceActions.tsx | 4 +- frontend/src/layout/today/todaySpacesLogic.ts | 31 ++++++-- frontend/src/products.tsx | 2 + .../composer/ComposerModelEffortPickers.tsx | 14 ++-- .../spaces/newSessionSceneLogic.test.ts | 20 ++++++ .../frontend/spaces/newSessionSceneLogic.ts | 70 ++++++++++++++----- .../frontend/spaces/spaceSceneLogic.test.ts | 19 +---- .../tasks/frontend/spaces/spaceSceneLogic.ts | 11 +-- products/tasks/manifest.tsx | 2 + 9 files changed, 114 insertions(+), 59 deletions(-) create mode 100644 products/tasks/frontend/spaces/newSessionSceneLogic.test.ts diff --git a/frontend/src/layout/today/TodaySpaceActions.tsx b/frontend/src/layout/today/TodaySpaceActions.tsx index eb25cb5cd974..be98e43cbe9f 100644 --- a/frontend/src/layout/today/TodaySpaceActions.tsx +++ b/frontend/src/layout/today/TodaySpaceActions.tsx @@ -16,7 +16,7 @@ import { urls } from 'scenes/urls' import { ChannelDTOApi } from 'products/tasks/frontend/generated/api.schemas' import { TodayMenuParts } from './todayMenuParts' -import { spaceNewSessionUrl, todaySpacesLogic } from './todaySpacesLogic' +import { todaySpacesLogic } from './todaySpacesLogic' interface TodaySpaceActionsProps { parts: TodayMenuParts @@ -42,7 +42,7 @@ export function TodaySpaceActions({ return ( <> - + New session diff --git a/frontend/src/layout/today/todaySpacesLogic.ts b/frontend/src/layout/today/todaySpacesLogic.ts index 55ae7cb80772..a96a5df72523 100644 --- a/frontend/src/layout/today/todaySpacesLogic.ts +++ b/frontend/src/layout/today/todaySpacesLogic.ts @@ -1,10 +1,11 @@ import { MakeLogicType, actions, afterMount, connect, kea, listeners, path, reducers, selectors } from 'kea' import { loaders } from 'kea-loaders' -import { combineUrl, router } from 'kea-router' +import { router } from 'kea-router' import type { LocationChangedPayload } from 'kea-router/lib/types' import { toast } from '@posthog/quill' +import { removeProjectIdIfPresent } from 'lib/utils/kea-router' import { writeToClipboard } from 'lib/utils/writeToClipboard' import { maxGlobalLogic } from 'scenes/max/maxGlobalLogic' import { teamLogic } from 'scenes/teamLogic' @@ -79,11 +80,10 @@ const SPACE_PRESENCE_POLL_INTERVAL_MS = 90_000 export type TodayWorkSectionId = 'pinned' | 'recent' | 'spaces' -/** The space page reads this search param once and focuses its new-session composer. */ -export const SPACE_COMPOSE_PARAM = 'compose' - -export function spaceNewSessionUrl(spaceId: string): string { - return combineUrl(urls.taskSpace(spaceId), { [SPACE_COMPOSE_PARAM]: 1 }).url +/** The space a path is in, like PostHog Desktop's scoped space. `/spaces/new` is in no space. */ +export function spaceIdForPath(pathname: string): string | null { + const match = removeProjectIdIfPresent(pathname).match(/^\/spaces\/([^/]+)/) + return match && match[1] !== 'new' ? match[1] : null } /** The personal space first, then the team's general space, then starred spaces, then the rest by name. */ @@ -133,6 +133,7 @@ export interface todaySpacesLogicValues { user: UserType | null // userLogic allRecentItems: TodayWorkItem[] collapsedSections: TodayWorkSectionId[] + lastSpaceId: string | null pendingSpaceIds: string[] pinnedItems: TodayWorkItem[] pinnedTasks: TaskListItemApi[] @@ -325,6 +326,9 @@ export interface todaySpacesLogicActions { setSectionHeights: (heights: Partial>) => { heights: Partial> } + spaceVisited: (spaceId: string) => { + spaceId: string + } starFailed: (spaceId: string) => { spaceId: string } @@ -408,6 +412,7 @@ export const todaySpacesLogic = kea([ toggleSection: (sectionId: TodayWorkSectionId) => ({ sectionId }), setSectionHeights: (heights: Partial>) => ({ heights }), resetSectionPair: (upper: TodayWorkSectionId, lower: TodayWorkSectionId) => ({ upper, lower }), + spaceVisited: (spaceId: string) => ({ spaceId }), setRecentQuery: (query: string) => ({ query }), setRecentSearchOpen: (open: boolean) => ({ open }), setRecentFilters: (filters: TodayRecentFilters) => ({ filters }), @@ -529,6 +534,8 @@ export const todaySpacesLogic = kea([ }, }, ], + // A generic New session files here, like PostHog Desktop's scoped space. A stale id falls back to personal. + lastSpaceId: [null as string | null, { persist: true }, { spaceVisited: (_, { spaceId }) => spaceId }], recentQuery: ['', { setRecentQuery: (_, { query }) => query, clearRecentSearchAndFilters: () => '' }], recentSearchOpen: [ false, @@ -709,7 +716,13 @@ export const todaySpacesLogic = kea([ } } return { - locationChanged: markOpenSessionRead, + locationChanged: ({ pathname }) => { + const spaceId = spaceIdForPath(pathname) + if (spaceId) { + actions.spaceVisited(spaceId) + } + markOpenSessionRead() + }, loadTaskActivitySuccess: markOpenSessionRead, loadRecentTasks: () => actions.loadTaskActivity(), markSessionRead: async ({ marker, activityIds }) => { @@ -758,6 +771,10 @@ export const todaySpacesLogic = kea([ }, })), afterMount(({ actions, cache }) => { + const spaceId = spaceIdForPath(router.values.location.pathname) + if (spaceId) { + actions.spaceVisited(spaceId) + } actions.loadSpaces() actions.loadPinnedTasks() actions.loadRecentTasks() diff --git a/frontend/src/products.tsx b/frontend/src/products.tsx index 0c25b9701eee..b4f907fb7aae 100644 --- a/frontend/src/products.tsx +++ b/frontend/src/products.tsx @@ -287,6 +287,7 @@ export const productRoutes: Record = { '/spaces/new': ['TaskNewSession', 'taskNewSession'], '/spaces/:id': ['TaskSpace', 'taskSpace'], '/spaces/:id/canvases': ['TaskSpace', 'taskSpaceCanvases'], + '/spaces/:id/new': ['TaskNewSession', 'taskSpaceNewSession'], '/spaces/:id/settings': ['TaskSpace', 'taskSpaceSettings'], '/tracing': ['Tracing', 'tracing'], '/tracing/operation': ['TracingOperation', 'tracingOperation'], @@ -1761,6 +1762,7 @@ export const productUrls = { taskNewSession: (): string => '/spaces/new', taskSpace: (id: string): string => `/spaces/${id}`, taskSpaceCanvases: (id: string): string => `/spaces/${id}/canvases`, + taskSpaceNewSession: (id: string): string => `/spaces/${id}/new`, taskSpaceSettings: (id: string): string => `/spaces/${id}/settings`, toolbarLaunch: (): string => '/toolbar', tracing: (): string => '/tracing', diff --git a/products/posthog_ai/frontend/components/composer/ComposerModelEffortPickers.tsx b/products/posthog_ai/frontend/components/composer/ComposerModelEffortPickers.tsx index 651beda01c0c..6f288ae949b3 100644 --- a/products/posthog_ai/frontend/components/composer/ComposerModelEffortPickers.tsx +++ b/products/posthog_ai/frontend/components/composer/ComposerModelEffortPickers.tsx @@ -74,7 +74,8 @@ export interface ComposerModelEffortPickersProps { */ lockedRuntimeAdapter?: string | null /** The selection shown is the resolved default (user/project preference), not an explicit pick for - * this run — the model trigger renders a "Default ·" prefix so that's visible at a glance. */ + * this run — the lemon model trigger renders a "Default ·" prefix so that's visible at a glance. The quill + * trigger shows only the model, like PostHog Desktop. */ isDefaultSelection?: boolean /** Clears the explicit pick so the run falls back to the resolved default. Omit on a surface with no * configured default and the reset row falls back to the ladder's balanced notch. */ @@ -99,9 +100,12 @@ interface PickerSectionProps { } /** One `label … current ›` row of the cascade, opening a radio list. */ -const PICKER_CHROME: Record = { - lemon: { triggerVariant: 'outline', icons: true }, - quill: { triggerVariant: 'default', icons: false }, +const PICKER_CHROME: Record< + ThreadSkin, + { triggerVariant: 'outline' | 'default'; icons: boolean; defaultPrefix: boolean } +> = { + lemon: { triggerVariant: 'outline', icons: true, defaultPrefix: true }, + quill: { triggerVariant: 'default', icons: false, defaultPrefix: false }, } function PickerSection({ title, current, value, onValueChange, children, footer }: PickerSectionProps): JSX.Element { @@ -242,7 +246,7 @@ export function ComposerModelEffortPickers({ - {isDefaultSelection ? `Default · ${modelLabel}` : modelLabel} + {isDefaultSelection && chrome.defaultPrefix ? `Default · ${modelLabel}` : modelLabel} {effortOptions.length > 0 && ( {getEffortLabel(selectedEffort)} )} diff --git a/products/tasks/frontend/spaces/newSessionSceneLogic.test.ts b/products/tasks/frontend/spaces/newSessionSceneLogic.test.ts new file mode 100644 index 000000000000..d2b229200832 --- /dev/null +++ b/products/tasks/frontend/spaces/newSessionSceneLogic.test.ts @@ -0,0 +1,20 @@ +import { ChannelDTOApi } from '../generated/api.schemas' +import { NewSessionSpaceSource, newSessionSpace } from './newSessionSceneLogic' + +const space = (id: string, systemRole: ChannelDTOApi['system_role'] = null): ChannelDTOApi => + ({ id, name: id, system_role: systemRole }) as ChannelDTOApi + +const SPACES = [space('general', 'general'), space('me', 'personal'), space('checkout')] + +describe('newSessionSpace', () => { + it.each<[string, string | null, string | null, string, NewSessionSpaceSource]>([ + ['a space’s own New session wins over the last space', 'checkout', 'general', 'checkout', 'route'], + ['a generic New session uses the last space', null, 'checkout', 'checkout', 'last_used'], + ['a generic New session with no last space uses personal', null, null, 'me', 'personal'], + ['a deleted last space falls back to personal', null, 'gone', 'me', 'personal'], + ['a deleted route space falls back to the last space', 'gone', 'checkout', 'checkout', 'last_used'], + ])('%s', (_, routeSpaceId, lastSpaceId, expectedId, expectedSource) => { + const { space: chosen, source } = newSessionSpace(SPACES, routeSpaceId, lastSpaceId) + expect({ id: chosen?.id, source }).toEqual({ id: expectedId, source: expectedSource }) + }) +}) diff --git a/products/tasks/frontend/spaces/newSessionSceneLogic.ts b/products/tasks/frontend/spaces/newSessionSceneLogic.ts index 67c7298c4620..eea639e33658 100644 --- a/products/tasks/frontend/spaces/newSessionSceneLogic.ts +++ b/products/tasks/frontend/spaces/newSessionSceneLogic.ts @@ -1,5 +1,5 @@ import { MakeLogicType, actions, connect, kea, listeners, path, reducers, selectors } from 'kea' -import { router } from 'kea-router' +import { router, urlToAction } from 'kea-router' import posthog from 'posthog-js' import { Scene } from 'scenes/sceneTypes' @@ -17,14 +17,28 @@ export interface NewSessionSpaceGroup { items: string[] } -/** Like PostHog Desktop: a new session files into the personal space until the user picks another one. */ -export function newSessionSpace(spaces: ChannelDTOApi[], pickedSpaceId: string | null): ChannelDTOApi | null { - return ( - spaces.find((space) => space.id === pickedSpaceId) ?? - spaces.find((space) => space.system_role === 'personal') ?? - spaces[0] ?? - null - ) +/** `route` is a space's own New session, or a pick in the heading. `last_used` and `personal` are the generic default. */ +export type NewSessionSpaceSource = 'route' | 'last_used' | 'personal' | 'first' + +export interface NewSessionSpaceChoice { + space: ChannelDTOApi | null + source: NewSessionSpaceSource | null +} + +/** Like PostHog Desktop: the space in the URL, else the last space the person was in, else their personal space. */ +export function newSessionSpace( + spaces: ChannelDTOApi[], + routeSpaceId: string | null, + lastSpaceId: string | null +): NewSessionSpaceChoice { + const candidates: [NewSessionSpaceSource, ChannelDTOApi | undefined][] = [ + ['route', spaces.find((space) => space.id === routeSpaceId)], + ['last_used', spaces.find((space) => space.id === lastSpaceId)], + ['personal', spaces.find((space) => space.system_role === 'personal')], + ['first', spaces[0]], + ] + const [source, space] = candidates.find(([, candidate]) => candidate) ?? [null, null] + return { space: space ?? null, source } } /** The personal and starred spaces first, then the rest, under the same headings as the Spaces page. */ @@ -39,13 +53,15 @@ export function newSessionSpaceGroups(spaces: ChannelDTOApi[]): NewSessionSpaceG // Generated by kea-typegen. Update if you're an agent, ignore if you're human. export interface newSessionSceneLogicValues { + lastSpaceId: string | null // todaySpacesLogic sortedSpaces: ChannelDTOApi[] // todaySpacesLogic spacesLoading: boolean // todaySpacesLogic spacesUnavailable: boolean // todaySpacesLogic breadcrumbs: Breadcrumb[] composerRepositoryConfig: SpaceComposerRepositoryConfig - pickedSpaceId: string | null + routeSpaceId: string | null space: ChannelDTOApi | null + spaceChoice: NewSessionSpaceChoice spaceGroups: NewSessionSpaceGroup[] } @@ -59,12 +75,16 @@ export interface newSessionSceneLogicActions { sessionStarted: (sessionId: string) => { sessionId: string } + setRouteSpaceId: (spaceId: string | null) => { + spaceId: string | null + } } // Generated by kea-typegen. Update if you're an agent, ignore if you're human. export interface newSessionSceneLogicMeta { __keaTypeGenInternalSelectorTypes: { - space: (sortedSpaces: ChannelDTOApi[], pickedSpaceId: string | null) => ChannelDTOApi | null + spaceChoice: (sortedSpaces: ChannelDTOApi[], routeSpaceId: any, lastSpaceId: any) => NewSessionSpaceChoice + space: (spaceChoice: any) => ChannelDTOApi | null spaceGroups: (sortedSpaces: ChannelDTOApi[]) => NewSessionSpaceGroup[] composerRepositoryConfig: (space: ChannelDTOApi | null) => SpaceComposerRepositoryConfig } @@ -80,21 +100,29 @@ export type newSessionSceneLogicType = MakeLogicType< export const newSessionSceneLogic = kea([ path(['products', 'tasks', 'spaces', 'newSessionSceneLogic']), connect(() => ({ - values: [todaySpacesLogic, ['sortedSpaces', 'spacesLoading', 'spacesUnavailable']], + values: [todaySpacesLogic, ['sortedSpaces', 'spacesLoading', 'spacesUnavailable', 'lastSpaceId']], actions: [todaySpacesLogic, ['loadSpaces', 'loadRecentTasks']], })), actions({ pickSpace: (spaceId: string) => ({ spaceId }), + setRouteSpaceId: (spaceId: string | null) => ({ spaceId }), sessionStarted: (sessionId: string) => ({ sessionId }), }), reducers({ - pickedSpaceId: [null as string | null, { pickSpace: (_, { spaceId }) => spaceId }], + routeSpaceId: [null as string | null, { setRouteSpaceId: (_, { spaceId }) => spaceId }], }), selectors({ + spaceChoice: [ + (s) => [s.sortedSpaces, s.routeSpaceId, s.lastSpaceId], + ( + sortedSpaces: ChannelDTOApi[], + routeSpaceId: string | null, + lastSpaceId: string | null + ): NewSessionSpaceChoice => newSessionSpace(sortedSpaces, routeSpaceId, lastSpaceId), + ], space: [ - (s) => [s.sortedSpaces, s.pickedSpaceId], - (sortedSpaces: ChannelDTOApi[], pickedSpaceId: string | null): ChannelDTOApi | null => - newSessionSpace(sortedSpaces, pickedSpaceId), + (s) => [s.spaceChoice], + (spaceChoice: NewSessionSpaceChoice): ChannelDTOApi | null => spaceChoice.space, ], spaceGroups: [ (s) => [s.sortedSpaces], @@ -113,14 +141,22 @@ export const newSessionSceneLogic = kea([ ], }), listeners(({ actions, values }) => ({ + // Like PostHog Desktop, a pick moves to that space's own New session, so it becomes the last space too. + pickSpace: ({ spaceId }) => { + router.actions.push(urls.taskSpaceNewSession(spaceId)) + }, sessionStarted: ({ sessionId }) => { // pinned: analytics event name and properties. Renaming them breaks dashboards. posthog.capture('today new session started', { space_role: values.space?.system_role ?? null, - space_picked: values.pickedSpaceId !== null, + space_source: values.spaceChoice.source, }) actions.loadRecentTasks() router.actions.push(urls.aiTask(sessionId)) }, })), + urlToAction(({ actions }) => ({ + [urls.taskNewSession()]: () => actions.setRouteSpaceId(null), + [urls.taskSpaceNewSession(':id')]: ({ id }) => actions.setRouteSpaceId(id ?? null), + })), ]) diff --git a/products/tasks/frontend/spaces/spaceSceneLogic.test.ts b/products/tasks/frontend/spaces/spaceSceneLogic.test.ts index d21f2112bb21..b912f0b7bfea 100644 --- a/products/tasks/frontend/spaces/spaceSceneLogic.test.ts +++ b/products/tasks/frontend/spaces/spaceSceneLogic.test.ts @@ -6,7 +6,7 @@ import { expectLogic } from 'kea-test-utils' import { urls } from 'scenes/urls' import { todaySessionMenuLogic } from '~/layout/today/todaySessionMenuLogic' -import { spaceNewSessionUrl, todaySpacesLogic } from '~/layout/today/todaySpacesLogic' +import { todaySpacesLogic } from '~/layout/today/todaySpacesLogic' import { useMocks } from '~/mocks/jest' import { initKeaTests } from '~/test/init' @@ -601,23 +601,6 @@ describe('spaceSceneLogic', () => { expect(writeText).toHaveBeenCalledWith('https://app.example.com/code/canvas/space-a/c-0') }) - it('focuses the composer once when a new session is requested for this space', async () => { - const logic = spaceSceneLogic({ id: 'space-a' }) - const other = spaceSceneLogic({ id: 'space-b' }) - logic.mount() - other.mount() - - router.actions.push(spaceNewSessionUrl('space-a')) - await expectLogic(logic).toFinishAllListeners() - expect(logic.values.composerFocusRequest).toBe(1) - expect(router.values.searchParams).toEqual({}) - - router.actions.push(urls.taskSpaceSettings('space-a')) - router.actions.push(urls.taskSpace('space-a')) - expect(logic.values.composerFocusRequest).toBe(1) - expect(other.values.composerFocusRequest).toBe(0) - }) - it('fills this space’s composer with a suggestion without sending it', async () => { const logic = spaceSceneLogic({ id: 'space-a' }) logic.mount() diff --git a/products/tasks/frontend/spaces/spaceSceneLogic.ts b/products/tasks/frontend/spaces/spaceSceneLogic.ts index 7134ebf79514..30014cb12c10 100644 --- a/products/tasks/frontend/spaces/spaceSceneLogic.ts +++ b/products/tasks/frontend/spaces/spaceSceneLogic.ts @@ -14,7 +14,7 @@ import { userLogic } from 'scenes/userLogic' import { recentSourceOptions } from '~/layout/today/todayRecentFilters' import { TodayRecentSort } from '~/layout/today/todayRecentOrder' import { todaySessionMenuLogic } from '~/layout/today/todaySessionMenuLogic' -import { SPACE_COMPOSE_PARAM, spaceLabel, todaySpacesLogic } from '~/layout/today/todaySpacesLogic' +import { spaceLabel, todaySpacesLogic } from '~/layout/today/todaySpacesLogic' import { TodayWorkItem, sessionItem } from '~/layout/today/todayWorkItems' import { Breadcrumb, TeamPublicType, TeamType, UserType } from '~/types' @@ -1018,15 +1018,6 @@ export const spaceSceneLogic = kea([ }, })), urlToAction(({ actions, props }) => ({ - [urls.taskSpace(':id')]: ({ id }, searchParams, hashParams) => { - if (id !== props.id || !searchParams[SPACE_COMPOSE_PARAM]) { - return - } - actions.focusComposer() - // Drop the param, so a reload or a back navigation does not focus the composer again. - const { [SPACE_COMPOSE_PARAM]: _compose, ...rest } = searchParams - router.actions.replace(urls.taskSpace(props.id), rest, hashParams) - }, [urls.taskSpaceCanvases(':id')]: ({ id }) => { if (id === props.id) { actions.ensureCanvases() diff --git a/products/tasks/manifest.tsx b/products/tasks/manifest.tsx index 60a82dc41fb5..06ceaca3f3c5 100644 --- a/products/tasks/manifest.tsx +++ b/products/tasks/manifest.tsx @@ -32,6 +32,7 @@ export const manifest: ProductManifest = { '/spaces/new': ['TaskNewSession', 'taskNewSession'], '/spaces/:id': ['TaskSpace', 'taskSpace'], '/spaces/:id/canvases': ['TaskSpace', 'taskSpaceCanvases'], + '/spaces/:id/new': ['TaskNewSession', 'taskSpaceNewSession'], '/spaces/:id/settings': ['TaskSpace', 'taskSpaceSettings'], }, redirects: {}, @@ -41,6 +42,7 @@ export const manifest: ProductManifest = { taskNewSession: (): string => '/spaces/new', taskSpace: (id: string): string => `/spaces/${id}`, taskSpaceCanvases: (id: string): string => `/spaces/${id}/canvases`, + taskSpaceNewSession: (id: string): string => `/spaces/${id}/new`, taskSpaceSettings: (id: string): string => `/spaces/${id}/settings`, }, fileSystemTypes: {}, From c8d2377f8de39333f3359a7bf9962cd04a1fe63d Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Fri, 2 Oct 2026 15:39:37 +0100 Subject: [PATCH 36/48] chore(tasks): refresh typegen and app url manifest for new session routes Generated-By: PostHog Desktop Task-Id: cf2d5082-cc69-4bf1-8ac5-d16302185ff0 --- products/tasks/frontend/spaces/newSessionSceneLogic.ts | 8 ++++++-- services/mcp/src/tools/links/app-url-manifest.json | 10 ++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/products/tasks/frontend/spaces/newSessionSceneLogic.ts b/products/tasks/frontend/spaces/newSessionSceneLogic.ts index eea639e33658..528404c41c7a 100644 --- a/products/tasks/frontend/spaces/newSessionSceneLogic.ts +++ b/products/tasks/frontend/spaces/newSessionSceneLogic.ts @@ -83,8 +83,12 @@ export interface newSessionSceneLogicActions { // Generated by kea-typegen. Update if you're an agent, ignore if you're human. export interface newSessionSceneLogicMeta { __keaTypeGenInternalSelectorTypes: { - spaceChoice: (sortedSpaces: ChannelDTOApi[], routeSpaceId: any, lastSpaceId: any) => NewSessionSpaceChoice - space: (spaceChoice: any) => ChannelDTOApi | null + spaceChoice: ( + sortedSpaces: ChannelDTOApi[], + routeSpaceId: string | null, + lastSpaceId: string | null + ) => NewSessionSpaceChoice + space: (spaceChoice: NewSessionSpaceChoice) => ChannelDTOApi | null spaceGroups: (sortedSpaces: ChannelDTOApi[]) => NewSessionSpaceGroup[] composerRepositoryConfig: (space: ChannelDTOApi | null) => SpaceComposerRepositoryConfig } diff --git a/services/mcp/src/tools/links/app-url-manifest.json b/services/mcp/src/tools/links/app-url-manifest.json index 1fa37516abdc..bd0d03d53e3d 100644 --- a/services/mcp/src/tools/links/app-url-manifest.json +++ b/services/mcp/src/tools/links/app-url-manifest.json @@ -1684,6 +1684,11 @@ "params": [], "scope": "project" }, + "taskNewSession": { + "template": "/spaces/new", + "params": [], + "scope": "project" + }, "taskSpace": { "template": "/spaces/{id}", "params": ["id"], @@ -1694,6 +1699,11 @@ "params": ["id"], "scope": "project" }, + "taskSpaceNewSession": { + "template": "/spaces/{id}/new", + "params": ["id"], + "scope": "project" + }, "taskSpaceSettings": { "template": "/spaces/{id}/settings", "params": ["id"], From 311d3694a23fc3383d4b47828c6f0fc085987792 Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Fri, 2 Oct 2026 15:39:48 +0100 Subject: [PATCH 37/48] test(mcp): update the generate-app-url snapshot for the new session routes Generated-By: PostHog Desktop Task-Id: cf2d5082-cc69-4bf1-8ac5-d16302185ff0 --- .../tests/unit/__snapshots__/tool-schemas/generate-app-url.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/mcp/tests/unit/__snapshots__/tool-schemas/generate-app-url.json b/services/mcp/tests/unit/__snapshots__/tool-schemas/generate-app-url.json index 09ee7e7b3a0b..11975eca45fc 100644 --- a/services/mcp/tests/unit/__snapshots__/tool-schemas/generate-app-url.json +++ b/services/mcp/tests/unit/__snapshots__/tool-schemas/generate-app-url.json @@ -13,7 +13,7 @@ "type": "object" }, "url": { - "description": "A path template copied verbatim from the catalog below (e.g. `/persons/{uuid}`). Its `{placeholders}` are filled from `params`. These slugs come from PostHog's canonical route table, so they are always correct — never pass a path that is not in this list.\n\n/account-connected/{kind}\n/account/credential-review\n/account/social-connected\n/activity-logs\n/activity/{tab}\n/agentic/account-mismatch\n/agentic/authorize\n/ai\n/ai-enrichment\n/ai-evals/datasets\n/ai-evals/datasets/{id}\n/ai-evals/evaluations\n/ai-evals/evaluations/offline/experiments\n/ai-evals/evaluations/offline/experiments/{experimentId}\n/ai-evals/evaluations/scorers\n/ai-evals/evaluations/scorers/{scorerId}\n/ai-evals/evaluations/scorers/{scorerId}/offline\n/ai-evals/evaluations/templates\n/ai-evals/evaluations/{id}\n/ai-evals/taggers\n/ai-evals/taggers/{id}\n/ai-gateway\n/ai-observability/clusters\n/ai-observability/clusters/{runId}/{clusterId}\n/ai-observability/dashboard\n/ai-observability/errors\n/ai-observability/generations\n/ai-observability/playground\n/ai-observability/reviews\n/ai-observability/self-driving\n/ai-observability/sentiment\n/ai-observability/sessions\n/ai-observability/sessions/{id}\n/ai-observability/tools\n/ai-observability/traces\n/ai-observability/traces/{id}\n/ai-observability/users\n/ai/history\n/alerts\n/approvals/{id}\n/autoresearch\n/autoresearch/new\n/autoresearch/{id}\n/billing/authorization_status\n/broadcasts\n/broadcasts/new\n/broadcasts/{id}\n/business-knowledge\n/business-knowledge/playground\n/business-knowledge/settings\n/business-knowledge/{id}\n/canvas\n/canvases/new\n/canvases/{id}\n/cli/authorize\n/cli/live\n/code-review\n/code/canvas/{channelId}/{dashboardId}\n/code/channel/{channelId}\n/code/loop/{loopId}\n/code/task/{taskId}\n/cohorts\n/cohorts/{id}\n/cohorts/{id}/calculation-history\n/connect/vercel/link\n/coupons/{campaign}\n/create-organization\n/customer_analytics\n/customer_analytics/accounts\n/customer_analytics/accounts/by-external-id/{externalId}\n/customer_analytics/accounts/{accountId}\n/customer_analytics/announcements\n/customer_analytics/configuration\n/customer_analytics/dashboard\n/customer_analytics/feature-requests\n/customer_analytics/feed\n/customer_analytics/journeys\n/customer_analytics/journeys/new\n/customer_analytics/journeys/templates\n/customer_analytics/journeys/{id}/edit\n/customer_analytics/notes\n/customer_analytics/tasks\n/dashboard\n/dashboard/templates/{templateId}/copy-to-project\n/dashboard/{id}\n/dashboard/{id}/sharing\n/dashboard/{id}/subscriptions\n/dashboard/{id}/subscriptions/{subscriptionId}\n/dashboard/{id}/tiles/{tileId}\n/data-catalog\n/data-catalog/metrics/{name}\n/data-management/actions\n/data-management/actions/new\n/data-management/actions/new/\n/data-management/actions/{id}\n/data-management/annotations\n/data-management/annotations/{id}\n/data-management/core-events\n/data-management/database\n/data-management/destinations\n/data-management/event-filtering\n/data-management/events\n/data-management/events/{id}\n/data-management/events/{id}/edit\n/data-management/history\n/data-management/ingestion-warnings\n/data-management/ingestion-warnings-v2\n/data-management/managed-viewsets\n/data-management/materialized-columns\n/data-management/properties\n/data-management/properties/{id}\n/data-management/properties/{id}/edit\n/data-management/revenue\n/data-management/schema\n/data-management/sources\n/data-management/sources/{id}/schemas\n/data-management/sources/{sourceId}/schemas/{schemaId}\n/data-management/transformations\n/data-management/variables\n/data-management/variables/{id}\n/data-management/variables/{id}/edit\n/data-management/warehouse-destinations\n/data-management/warehouse-properties\n/data-ops\n/data-warehouse/connect\n/data-warehouse/new-source\n/debug\n/debug/hog\n/debug/precompute\n/early_access_features\n/early_access_features/{id}\n/embedded/{token}\n/endpoints\n/endpoints/{name}\n/engineering-analytics/authors\n/engineering-analytics/authors/{handle}\n/engineering-analytics/deploys\n/engineering-analytics/overview\n/engineering-analytics/pull-requests\n/engineering-analytics/repos/{repoOwner}/{repoName}/actions/runs/{runId}\n/engineering-analytics/repos/{repoOwner}/{repoName}/actions/workflows/{workflowName}\n/engineering-analytics/repos/{repoOwner}/{repoName}/pull-requests/{number}\n/engineering-analytics/teams\n/engineering-analytics/teams/{ownerTeam}\n/engineering-analytics/tests\n/engineering-analytics/workflows\n/error_tracking\n/error_tracking/alerts/new/{templateId}\n/error_tracking/alerts/{id}\n/error_tracking/fingerprint/{fingerprint}\n/error_tracking/{id}\n/etl\n/events/{id}/{timestamp}\n/experiments\n/experiments/shared-metrics\n/experiments/shared-metrics/{id}\n/experiments/staff\n/experiments/{id}\n/exports\n/feature_flags\n/feature_flags/new\n/feature_flags/staff\n/feature_flags/staff/cohorts\n/feature_flags/templates\n/feature_flags/{id}\n/files\n/functions/new/{templateId}\n/functions/{id}\n/games/368hedgehogs\n/games/flappyhog\n/games/shipit\n/groups/{groupTypeIndex}\n/groups/{groupTypeIndex}/new\n/groups/{groupTypeIndex}/{groupKey}\n/health\n/health/alerts\n/health/pipeline-status\n/health/sdk-health\n/health/{category}\n/heatmaps\n/heatmaps/new\n/heatmaps/recording\n/heatmaps/{id}\n/home\n/identity-matching\n/inbox\n/inbox/reports/triage\n/inbox/scouts/findings\n/inbox/scouts/runs\n/inbox/scouts/scratchpad\n/inbox/scouts/{skillName}\n/inbox/{tab}/{reportId}\n/insights\n/insights/new\n/insights/quick-start\n/insights/{id}\n/insights/{id}/edit\n/insights/{id}/sharing\n/insights/{id}/subscriptions\n/insights/{id}/subscriptions/{subscriptionId}\n/insights/{insightShortId}/alerts\n/instance/async_migrations\n/instance/async_migrations/future\n/instance/async_migrations/settings\n/instance/dead_letter_queue\n/instance/kafka_inspector\n/instance/metrics\n/instance/settings\n/instance/staff_users\n/instance/status\n/integrations/stripe/confirm-install\n/integrations/vercel/link-error\n/integrations/{kind}/callback\n/integrations/{slug}\n/legal\n/legal/new/{type}\n/link/{id}\n/links\n/live-debugger\n/login\n/login/2fa\n/login/2fa_setup\n/logs\n/logs/alerts/{alertId}/notifications/{hogFunctionId}\n/logs/alerts/{id}\n/logs/drop-rules/new\n/logs/drop-rules/{id}\n/logs/retention-rules/new\n/logs/retention-rules/{id}\n/managed_migrations\n/managed_migrations/new\n/marketing\n/mcp-analytics\n/mcp-analytics/activity\n/mcp-analytics/dashboard\n/mcp-analytics/intent-clustering\n/mcp-analytics/missing-capabilities\n/mcp-analytics/notifications\n/mcp-analytics/sessions\n/mcp-analytics/tool-quality\n/mcp-analytics/tool-quality/{toolName}\n/mcp-registry\n/mcp-servers\n/mcp-servers/agent/{id}\n/mcp-servers/member/{id}\n/mcp-servers/server/{id}\n/mcp-servers/{tab}\n/metrics\n/models\n/models/{id}\n/move-to-cloud\n/my-tickets\n/notebooks\n/notebooks/widgets/{widgetId}\n/notebooks/{shortId}\n/oauth/authorize\n/onboarding\n/organization-deactivated\n/organization-pending-deletion\n/organization/billing\n/organization/billing/overview\n/organization/billing/real-time-usage\n/organization/confirm-creation\n/organization/create-project\n/person/{id}\n/persons\n/persons/{uuid}\n/pipeline/batch-exports/new/{service}\n/pipeline/batch-exports/{id}\n/pipeline/new/\n/pipeline/plugins/{id}\n/preflight\n/product_tours\n/product_tours/{id}\n/project-pending-deletion\n/prompt-management/prompts\n/prompt-management/prompts/{name}\n/pulse\n/replay-vision\n/replay-vision/new/template\n/replay-vision/observations/{observationId}\n/replay-vision/{id}/budget\n/replay-vision/{id}/configure\n/replay-vision/{id}/details\n/replay-vision/{id}/overview\n/replay-vision/{id}/self-driving\n/replay-vision/{id}/template\n/replay-vision/{id}/triggers\n/replay/file-playback\n/replay/home\n/replay/kiosk\n/replay/playlists/{id}\n/replay/settings\n/replay/{id}\n/reset\n/reset/{userUuid}/{token}\n/reset_2fa/{userUuid}/{token}\n/resource-transfer/{resourceKind}/{resourceId}\n/sessions/{id}\n/settings/environment-approvals\n/settings/organization-authentication/{feature}/{configId}\n/settings/project\n/settings/user-feature-previews\n/shared/{token}\n/shared_dashboard/{shareToken}\n/signup\n/signup/{id}\n/site/{url}\n/skills\n/skills/community\n/skills/{categoryTab}\n/skills/{name}\n/slack-task-context\n/spaces\n/spaces/{id}\n/spaces/{id}/canvases\n/spaces/{id}/settings\n/sql\n/stamphog\n/stamphog/digests\n/stamphog/install/callback\n/stamphog/runs\n/startups\n/streamlit-apps\n/streamlit-apps/new\n/streamlit-apps/{id}\n/streamlit-apps/{id}/edit\n/subscriptions\n/subscriptions/new\n/subscriptions/{id}\n/subscriptions/{id}/edit\n/support\n/support/settings\n/support/tickets\n/support/tickets/{ticketId}\n/surveys\n/surveys/form/new\n/surveys/guided/new\n/surveys/{id}\n/tasks\n/tasks/new\n/tasks/{taskId}\n/terminal\n/themes/custom-css\n/toolbar\n/tracing\n/tracing/retention-rules/new\n/tracing/retention-rules/{id}\n/unsubscribe\n/user_research\n/user_research/{id}\n/user_research/{topicId}/response/{responseId}\n/verify_email\n/views\n/visual_review\n/visual_review/repos/{repoId}/flakiness\n/visual_review/repos/{repoId}/runs\n/visual_review/repos/{repoId}/snapshots\n/visual_review/repos/{repoId}/{runType}/snapshots/{identifier}\n/visual_review/runs/{runId}\n/visual_review/settings\n/web\n/web-scripts\n/web-scripts/new\n/web/agents\n/web/bots\n/web/content-autopilot\n/web/health\n/web/live\n/web/marketing\n/web/page-performance\n/web/page-reports\n/web/recap\n/web/session-attribution-explorer\n/web/web-vitals\n/wizard/runs\n/workflows\n/workflows/library/messages/{id}\n/workflows/library/templates/new\n/workflows/library/templates/{id}\n/workflows/new/workflow\n/workflows/{id}/{tab}", + "description": "A path template copied verbatim from the catalog below (e.g. `/persons/{uuid}`). Its `{placeholders}` are filled from `params`. These slugs come from PostHog's canonical route table, so they are always correct — never pass a path that is not in this list.\n\n/account-connected/{kind}\n/account/credential-review\n/account/social-connected\n/activity-logs\n/activity/{tab}\n/agentic/account-mismatch\n/agentic/authorize\n/ai\n/ai-enrichment\n/ai-evals/datasets\n/ai-evals/datasets/{id}\n/ai-evals/evaluations\n/ai-evals/evaluations/offline/experiments\n/ai-evals/evaluations/offline/experiments/{experimentId}\n/ai-evals/evaluations/scorers\n/ai-evals/evaluations/scorers/{scorerId}\n/ai-evals/evaluations/scorers/{scorerId}/offline\n/ai-evals/evaluations/templates\n/ai-evals/evaluations/{id}\n/ai-evals/taggers\n/ai-evals/taggers/{id}\n/ai-gateway\n/ai-observability/clusters\n/ai-observability/clusters/{runId}/{clusterId}\n/ai-observability/dashboard\n/ai-observability/errors\n/ai-observability/generations\n/ai-observability/playground\n/ai-observability/reviews\n/ai-observability/self-driving\n/ai-observability/sentiment\n/ai-observability/sessions\n/ai-observability/sessions/{id}\n/ai-observability/tools\n/ai-observability/traces\n/ai-observability/traces/{id}\n/ai-observability/users\n/ai/history\n/alerts\n/approvals/{id}\n/autoresearch\n/autoresearch/new\n/autoresearch/{id}\n/billing/authorization_status\n/broadcasts\n/broadcasts/new\n/broadcasts/{id}\n/business-knowledge\n/business-knowledge/playground\n/business-knowledge/settings\n/business-knowledge/{id}\n/canvas\n/canvases/new\n/canvases/{id}\n/cli/authorize\n/cli/live\n/code-review\n/code/canvas/{channelId}/{dashboardId}\n/code/channel/{channelId}\n/code/loop/{loopId}\n/code/task/{taskId}\n/cohorts\n/cohorts/{id}\n/cohorts/{id}/calculation-history\n/connect/vercel/link\n/coupons/{campaign}\n/create-organization\n/customer_analytics\n/customer_analytics/accounts\n/customer_analytics/accounts/by-external-id/{externalId}\n/customer_analytics/accounts/{accountId}\n/customer_analytics/announcements\n/customer_analytics/configuration\n/customer_analytics/dashboard\n/customer_analytics/feature-requests\n/customer_analytics/feed\n/customer_analytics/journeys\n/customer_analytics/journeys/new\n/customer_analytics/journeys/templates\n/customer_analytics/journeys/{id}/edit\n/customer_analytics/notes\n/customer_analytics/tasks\n/dashboard\n/dashboard/templates/{templateId}/copy-to-project\n/dashboard/{id}\n/dashboard/{id}/sharing\n/dashboard/{id}/subscriptions\n/dashboard/{id}/subscriptions/{subscriptionId}\n/dashboard/{id}/tiles/{tileId}\n/data-catalog\n/data-catalog/metrics/{name}\n/data-management/actions\n/data-management/actions/new\n/data-management/actions/new/\n/data-management/actions/{id}\n/data-management/annotations\n/data-management/annotations/{id}\n/data-management/core-events\n/data-management/database\n/data-management/destinations\n/data-management/event-filtering\n/data-management/events\n/data-management/events/{id}\n/data-management/events/{id}/edit\n/data-management/history\n/data-management/ingestion-warnings\n/data-management/ingestion-warnings-v2\n/data-management/managed-viewsets\n/data-management/materialized-columns\n/data-management/properties\n/data-management/properties/{id}\n/data-management/properties/{id}/edit\n/data-management/revenue\n/data-management/schema\n/data-management/sources\n/data-management/sources/{id}/schemas\n/data-management/sources/{sourceId}/schemas/{schemaId}\n/data-management/transformations\n/data-management/variables\n/data-management/variables/{id}\n/data-management/variables/{id}/edit\n/data-management/warehouse-destinations\n/data-management/warehouse-properties\n/data-ops\n/data-warehouse/connect\n/data-warehouse/new-source\n/debug\n/debug/hog\n/debug/precompute\n/early_access_features\n/early_access_features/{id}\n/embedded/{token}\n/endpoints\n/endpoints/{name}\n/engineering-analytics/authors\n/engineering-analytics/authors/{handle}\n/engineering-analytics/deploys\n/engineering-analytics/overview\n/engineering-analytics/pull-requests\n/engineering-analytics/repos/{repoOwner}/{repoName}/actions/runs/{runId}\n/engineering-analytics/repos/{repoOwner}/{repoName}/actions/workflows/{workflowName}\n/engineering-analytics/repos/{repoOwner}/{repoName}/pull-requests/{number}\n/engineering-analytics/teams\n/engineering-analytics/teams/{ownerTeam}\n/engineering-analytics/tests\n/engineering-analytics/workflows\n/error_tracking\n/error_tracking/alerts/new/{templateId}\n/error_tracking/alerts/{id}\n/error_tracking/fingerprint/{fingerprint}\n/error_tracking/{id}\n/etl\n/events/{id}/{timestamp}\n/experiments\n/experiments/shared-metrics\n/experiments/shared-metrics/{id}\n/experiments/staff\n/experiments/{id}\n/exports\n/feature_flags\n/feature_flags/new\n/feature_flags/staff\n/feature_flags/staff/cohorts\n/feature_flags/templates\n/feature_flags/{id}\n/files\n/functions/new/{templateId}\n/functions/{id}\n/games/368hedgehogs\n/games/flappyhog\n/games/shipit\n/groups/{groupTypeIndex}\n/groups/{groupTypeIndex}/new\n/groups/{groupTypeIndex}/{groupKey}\n/health\n/health/alerts\n/health/pipeline-status\n/health/sdk-health\n/health/{category}\n/heatmaps\n/heatmaps/new\n/heatmaps/recording\n/heatmaps/{id}\n/home\n/identity-matching\n/inbox\n/inbox/reports/triage\n/inbox/scouts/findings\n/inbox/scouts/runs\n/inbox/scouts/scratchpad\n/inbox/scouts/{skillName}\n/inbox/{tab}/{reportId}\n/insights\n/insights/new\n/insights/quick-start\n/insights/{id}\n/insights/{id}/edit\n/insights/{id}/sharing\n/insights/{id}/subscriptions\n/insights/{id}/subscriptions/{subscriptionId}\n/insights/{insightShortId}/alerts\n/instance/async_migrations\n/instance/async_migrations/future\n/instance/async_migrations/settings\n/instance/dead_letter_queue\n/instance/kafka_inspector\n/instance/metrics\n/instance/settings\n/instance/staff_users\n/instance/status\n/integrations/stripe/confirm-install\n/integrations/vercel/link-error\n/integrations/{kind}/callback\n/integrations/{slug}\n/legal\n/legal/new/{type}\n/link/{id}\n/links\n/live-debugger\n/login\n/login/2fa\n/login/2fa_setup\n/logs\n/logs/alerts/{alertId}/notifications/{hogFunctionId}\n/logs/alerts/{id}\n/logs/drop-rules/new\n/logs/drop-rules/{id}\n/logs/retention-rules/new\n/logs/retention-rules/{id}\n/managed_migrations\n/managed_migrations/new\n/marketing\n/mcp-analytics\n/mcp-analytics/activity\n/mcp-analytics/dashboard\n/mcp-analytics/intent-clustering\n/mcp-analytics/missing-capabilities\n/mcp-analytics/notifications\n/mcp-analytics/sessions\n/mcp-analytics/tool-quality\n/mcp-analytics/tool-quality/{toolName}\n/mcp-registry\n/mcp-servers\n/mcp-servers/agent/{id}\n/mcp-servers/member/{id}\n/mcp-servers/server/{id}\n/mcp-servers/{tab}\n/metrics\n/models\n/models/{id}\n/move-to-cloud\n/my-tickets\n/notebooks\n/notebooks/widgets/{widgetId}\n/notebooks/{shortId}\n/oauth/authorize\n/onboarding\n/organization-deactivated\n/organization-pending-deletion\n/organization/billing\n/organization/billing/overview\n/organization/billing/real-time-usage\n/organization/confirm-creation\n/organization/create-project\n/person/{id}\n/persons\n/persons/{uuid}\n/pipeline/batch-exports/new/{service}\n/pipeline/batch-exports/{id}\n/pipeline/new/\n/pipeline/plugins/{id}\n/preflight\n/product_tours\n/product_tours/{id}\n/project-pending-deletion\n/prompt-management/prompts\n/prompt-management/prompts/{name}\n/pulse\n/replay-vision\n/replay-vision/new/template\n/replay-vision/observations/{observationId}\n/replay-vision/{id}/budget\n/replay-vision/{id}/configure\n/replay-vision/{id}/details\n/replay-vision/{id}/overview\n/replay-vision/{id}/self-driving\n/replay-vision/{id}/template\n/replay-vision/{id}/triggers\n/replay/file-playback\n/replay/home\n/replay/kiosk\n/replay/playlists/{id}\n/replay/settings\n/replay/{id}\n/reset\n/reset/{userUuid}/{token}\n/reset_2fa/{userUuid}/{token}\n/resource-transfer/{resourceKind}/{resourceId}\n/sessions/{id}\n/settings/environment-approvals\n/settings/organization-authentication/{feature}/{configId}\n/settings/project\n/settings/user-feature-previews\n/shared/{token}\n/shared_dashboard/{shareToken}\n/signup\n/signup/{id}\n/site/{url}\n/skills\n/skills/community\n/skills/{categoryTab}\n/skills/{name}\n/slack-task-context\n/spaces\n/spaces/new\n/spaces/{id}\n/spaces/{id}/canvases\n/spaces/{id}/new\n/spaces/{id}/settings\n/sql\n/stamphog\n/stamphog/digests\n/stamphog/install/callback\n/stamphog/runs\n/startups\n/streamlit-apps\n/streamlit-apps/new\n/streamlit-apps/{id}\n/streamlit-apps/{id}/edit\n/subscriptions\n/subscriptions/new\n/subscriptions/{id}\n/subscriptions/{id}/edit\n/support\n/support/settings\n/support/tickets\n/support/tickets/{ticketId}\n/surveys\n/surveys/form/new\n/surveys/guided/new\n/surveys/{id}\n/tasks\n/tasks/new\n/tasks/{taskId}\n/terminal\n/themes/custom-css\n/toolbar\n/tracing\n/tracing/retention-rules/new\n/tracing/retention-rules/{id}\n/unsubscribe\n/user_research\n/user_research/{id}\n/user_research/{topicId}/response/{responseId}\n/verify_email\n/views\n/visual_review\n/visual_review/repos/{repoId}/flakiness\n/visual_review/repos/{repoId}/runs\n/visual_review/repos/{repoId}/snapshots\n/visual_review/repos/{repoId}/{runType}/snapshots/{identifier}\n/visual_review/runs/{runId}\n/visual_review/settings\n/web\n/web-scripts\n/web-scripts/new\n/web/agents\n/web/bots\n/web/content-autopilot\n/web/health\n/web/live\n/web/marketing\n/web/page-performance\n/web/page-reports\n/web/recap\n/web/session-attribution-explorer\n/web/web-vitals\n/wizard/runs\n/workflows\n/workflows/library/messages/{id}\n/workflows/library/templates/new\n/workflows/library/templates/{id}\n/workflows/new/workflow\n/workflows/{id}/{tab}", "type": "string" } }, From 4e8d65a9277fc0945667d58ab3dbe8dd1d033e17 Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Fri, 2 Oct 2026 16:45:32 +0200 Subject: [PATCH 38/48] fix(sql-editor): rerun bi queries without reusable results --- .../editor/SQLEditorScene.stories.tsx | 2 +- .../data-warehouse/editor/bi/biEditorLogic.ts | 7 +- .../editor/sqlEditorLogic.test.ts | 75 ++++++++++++------- 3 files changed, 55 insertions(+), 29 deletions(-) diff --git a/frontend/src/scenes/data-warehouse/editor/SQLEditorScene.stories.tsx b/frontend/src/scenes/data-warehouse/editor/SQLEditorScene.stories.tsx index 06083bf9adad..ca3b43f3090d 100644 --- a/frontend/src/scenes/data-warehouse/editor/SQLEditorScene.stories.tsx +++ b/frontend/src/scenes/data-warehouse/editor/SQLEditorScene.stories.tsx @@ -10,9 +10,9 @@ import { mswDecorator } from '~/mocks/browser' import type { DataWarehouseSavedQuery } from '~/types' import { AccessControlLevel, AccessControlResourceType, ChartDisplayType } from '~/types' -import { BIConfig, BIField, buildBIQuery } from './bi/biEditorTypes' import { expect, userEvent, waitFor, within } from 'storybook/test' +import { BIConfig, BIField, buildBIQuery } from './bi/biEditorTypes' import { QueryInfo } from './output-pane-tabs/QueryInfo' import { sqlEditorLogic } from './sqlEditorLogic' diff --git a/frontend/src/scenes/data-warehouse/editor/bi/biEditorLogic.ts b/frontend/src/scenes/data-warehouse/editor/bi/biEditorLogic.ts index d205336a3413..a46345cf2ebe 100644 --- a/frontend/src/scenes/data-warehouse/editor/bi/biEditorLogic.ts +++ b/frontend/src/scenes/data-warehouse/editor/bi/biEditorLogic.ts @@ -777,9 +777,14 @@ export const biEditorLogic = kea([ const editorLogic = sqlEditorLogic({ tabId: logicProps.tabId }) const lastSource = editorLogic.values.lastRunQuery?.source const nextSource = values.generatedQuery.node.source + const dataLogic = dataNodeLogic.findMounted({ key: `data-warehouse-editor-data-node-${logicProps.tabId}` }) if ( lastSource?.query === nextSource.query && - (lastSource.connectionId ?? null) === (nextSource.connectionId ?? null) + (lastSource.connectionId ?? null) === (nextSource.connectionId ?? null) && + dataLogic && + !dataLogic.values.queryCancelled && + !dataLogic.values.responseError && + (dataLogic.values.responseLoading || dataLogic.values.response) ) { return } diff --git a/frontend/src/scenes/data-warehouse/editor/sqlEditorLogic.test.ts b/frontend/src/scenes/data-warehouse/editor/sqlEditorLogic.test.ts index a8bf2b255b8f..d298f95624f6 100644 --- a/frontend/src/scenes/data-warehouse/editor/sqlEditorLogic.test.ts +++ b/frontend/src/scenes/data-warehouse/editor/sqlEditorLogic.test.ts @@ -2561,34 +2561,52 @@ describe('sqlEditorLogic', () => { biLogic.unmount() }) - it('reuses results for chart-only changes but runs changed pivot SQL', async () => { - logic = sqlEditorLogic({ tabId: TAB_ID, monaco: createMockMonaco(), editor: createMockEditor() }) - logic.mount() - const biLogic = biEditorLogic({ tabId: TAB_ID }) - biLogic.mount() - biLogic.actions.restoreState({ - editorView: BIEditorView.BI, - config: { ...config, columns: [timestampField] }, - }) - biLogic.actions.syncGeneratedQuery() - logic.actions.setLastRunQuery(logic.values.sourceQuery) - const runQuery = jest.spyOn(logic.actions, 'runQuery') - jest.useFakeTimers() - try { - biLogic.actions.setAutoUpdate(true) - biLogic.actions.setChartType(ChartDisplayType.ActionsTable) - await jest.advanceTimersByTimeAsync(500) - expect(logic.values.sourceQuery.display).toBe(ChartDisplayType.ActionsTable) - expect(runQuery).not.toHaveBeenCalled() - biLogic.actions.setChartType(ChartDisplayType.TwoDimensionalHeatmap) - await jest.advanceTimersByTimeAsync(500) - expect(runQuery).toHaveBeenCalledTimes(1) - } finally { - jest.useRealTimers() - runQuery.mockRestore() - biLogic.unmount() + test.each(['ready', 'failed', 'cancelled', 'missing'] as const)( + 'handles chart-only changes with %s query results and runs changed pivot SQL', + async (resultState) => { + logic = sqlEditorLogic({ tabId: TAB_ID, monaco: createMockMonaco(), editor: createMockEditor() }) + logic.mount() + const biLogic = biEditorLogic({ tabId: TAB_ID }) + biLogic.mount() + biLogic.actions.restoreState({ + editorView: BIEditorView.BI, + config: { ...config, columns: [timestampField] }, + }) + biLogic.actions.syncGeneratedQuery() + logic.actions.setLastRunQuery(logic.values.sourceQuery) + const dataLogic = dataNodeLogic({ + key: `data-warehouse-editor-data-node-${TAB_ID}`, + query: logic.values.sourceQuery.source, + autoLoad: false, + }) + dataLogic.mount() + if (resultState !== 'missing') { + dataLogic.actions.setResponse({ results: [[1]], columns: ['1'], types: ['Int64'] }) + } + if (resultState === 'failed') { + dataLogic.actions.loadDataFailure('Query failed', { detail: 'Query failed' }) + } else if (resultState === 'cancelled') { + dataLogic.actions.cancelQuery() + } + const runQuery = jest.spyOn(logic.actions, 'runQuery') + jest.useFakeTimers() + try { + biLogic.actions.setAutoUpdate(true) + biLogic.actions.setChartType(ChartDisplayType.ActionsTable) + await jest.advanceTimersByTimeAsync(500) + expect(logic.values.sourceQuery.display).toBe(ChartDisplayType.ActionsTable) + expect(runQuery).toHaveBeenCalledTimes(resultState === 'ready' ? 0 : 1) + biLogic.actions.setChartType(ChartDisplayType.TwoDimensionalHeatmap) + await jest.advanceTimersByTimeAsync(500) + expect(runQuery).toHaveBeenCalledTimes(resultState === 'ready' ? 1 : 2) + } finally { + jest.useRealTimers() + runQuery.mockRestore() + dataLogic.unmount() + biLogic.unmount() + } } - }) + ) test.each(['disable', 'sql', 'clear', 'unchanged'] as const)( 'handles a pending automatic query when the worksheet is %s', @@ -2648,6 +2666,9 @@ describe('sqlEditorLogic', () => { }) it('restores BI mode and configuration from the URL and keeps changes in the hash', async () => { + featureFlagLogic.actions.setFeatureFlags([FEATURE_FLAGS.SQL_EDITOR_BI_MODE], { + [FEATURE_FLAGS.SQL_EDITOR_BI_MODE]: true, + }) logic = sqlEditorLogic({ tabId: TAB_ID, monaco: createMockMonaco(), From e93aed49af118b347fe8eb6b6b2e6616683f2354 Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Fri, 2 Oct 2026 15:50:48 +0100 Subject: [PATCH 39/48] feat(autoresearch): have the training agent build a report notebook Behind the autoresearch-report-notebook flag, grant the training sandbox notebook:read and notebook:write and add a Finalize step: the agent builds one report notebook from the system.autoresearch_* tables and passes its short_id to complete. report.md stays required. complete_training_run stores report_notebook_short_id in the run summary only if the notebook exists in the run's team. A bad id or a failed check stores an empty value and never fails completion. Co-Authored-By: Claude Opus 5.5 Generated-By: PostHog Desktop Task-Id: d418feae-635a-4fa8-a762-6defae581a72 --- products/autoresearch/backend/access.py | 37 ++++++-- products/autoresearch/backend/facade/api.py | 2 + .../autoresearch/backend/facade/contracts.py | 1 + .../backend/presentation/views/serializers.py | 15 ++++ .../backend/presentation/views/views.py | 1 + .../autoresearch/backend/training/AGENTS.md | 2 + .../backend/training/promotion.py | 25 ++++++ .../autoresearch/backend/training/runner.py | 89 ++++++++++++++++++- .../backend/training/test_promotion.py | 19 ++++ .../backend/training/test_training.py | 37 ++++++-- .../frontend/generated/api.schemas.ts | 4 + .../frontend/generated/api.zod.ts | 8 ++ products/autoresearch/mcp/tools.yaml | 2 +- .../schema/generated-tool-definitions.json | 2 +- services/mcp/schema/tool-definitions-all.json | 2 +- services/mcp/src/api/generated.ts | 4 + .../mcp/src/generated/autoresearch/api.ts | 8 ++ .../mcp/src/tools/generated/autoresearch.ts | 3 + tach.toml | 1 + 19 files changed, 240 insertions(+), 22 deletions(-) diff --git a/products/autoresearch/backend/access.py b/products/autoresearch/backend/access.py index bddd66024ca1..0bc5ba1fff1b 100644 --- a/products/autoresearch/backend/access.py +++ b/products/autoresearch/backend/access.py @@ -1,7 +1,8 @@ -"""Feature-flag gate for the autoresearch product. +"""Feature-flag gates for the autoresearch product. -Access is controlled by the `autoresearch` feature flag. Rollout is configured -on the flag in PostHog, so code only asks whether it's enabled for this user/team. +Access is controlled by the `autoresearch` feature flag. The `autoresearch-report-notebook` +flag gates the report notebook that a training run builds. Rollout is configured on each +flag in PostHog, so code only asks whether it's enabled for this user/team. """ from django.conf import settings @@ -15,6 +16,7 @@ from products.feature_flags.backend.facade import api as feature_flags_facade AUTORESEARCH_FLAG = "autoresearch" +REPORT_NOTEBOOK_FLAG = "autoresearch-report-notebook" def has_autoresearch_access( @@ -22,6 +24,25 @@ def has_autoresearch_access( *, team_id: int | None = None, organization_id: str | None = None, +) -> bool: + return _flag_enabled(AUTORESEARCH_FLAG, user, team_id=team_id, organization_id=organization_id) + + +def has_report_notebook_access( + user: AbstractBaseUser | AnonymousUser | None, + *, + team_id: int | None = None, + organization_id: str | None = None, +) -> bool: + return _flag_enabled(REPORT_NOTEBOOK_FLAG, user, team_id=team_id, organization_id=organization_id) + + +def _flag_enabled( + flag: str, + user: AbstractBaseUser | AnonymousUser | None, + *, + team_id: int | None, + organization_id: str | None, ) -> bool: if not user or not user.is_authenticated: return False @@ -34,7 +55,7 @@ def has_autoresearch_access( # fail closed rather than grant access on any active flag row. # Don't apply this in TEST mode, because tests mock feature_enabled directly. if settings.DEBUG and not getattr(settings, "TEST", False): - return _local_flag_enabled(team_id=team_id) + return _local_flag_enabled(flag, team_id=team_id) if team_id is not None and organization_id is None: organization_id = _organization_id_for_team(team_id) @@ -46,7 +67,7 @@ def has_autoresearch_access( if organization_id is not None: return bool( posthog_feature_flag_value( - AUTORESEARCH_FLAG, + flag, distinct_id, organization_id=organization_id, team_id=team_id, @@ -62,7 +83,7 @@ def has_autoresearch_access( return bool( posthoganalytics.feature_enabled( - AUTORESEARCH_FLAG, + flag, distinct_id, groups=groups, group_properties=group_properties, @@ -77,5 +98,5 @@ def _organization_id_for_team(team_id: int) -> str | None: return str(organization_id) if organization_id else None -def _local_flag_enabled(*, team_id: int | None) -> bool: - return feature_flags_facade.flag_is_active(AUTORESEARCH_FLAG, team_id=team_id) +def _local_flag_enabled(flag: str, *, team_id: int | None) -> bool: + return feature_flags_facade.flag_is_active(flag, team_id=team_id) diff --git a/products/autoresearch/backend/facade/api.py b/products/autoresearch/backend/facade/api.py index 9305770ec29a..5a656ee37baf 100644 --- a/products/autoresearch/backend/facade/api.py +++ b/products/autoresearch/backend/facade/api.py @@ -1059,6 +1059,7 @@ def complete_run( model_explanation: dict[str, Any] | None = None, recommended_next: str = "", distillation: str = "", + report_notebook_short_id: str = "", ) -> TrainingRun: """Finalize a run. Promotion is server-side, so an agent cannot set the champion.""" # Promotion imports the inference sandbox, and with it pandas and pyarrow; the router imports @@ -1078,6 +1079,7 @@ def complete_run( model_explanation=model_explanation or {}, recommended_next=recommended_next or "", distillation=distillation or "", + report_notebook_short_id=report_notebook_short_id or "", ) except PromotionError as exc: raise AutoresearchConflict(str(exc)) from exc diff --git a/products/autoresearch/backend/facade/contracts.py b/products/autoresearch/backend/facade/contracts.py index 85e3b5b7277b..e2bd5e7b4c34 100644 --- a/products/autoresearch/backend/facade/contracts.py +++ b/products/autoresearch/backend/facade/contracts.py @@ -163,6 +163,7 @@ class TrainingRunSummary: dead_ends: list[TrainingRunSummaryLadderItem] recommended_next: str distillation: str + report_notebook_short_id: str @dataclass(frozen=True) diff --git a/products/autoresearch/backend/presentation/views/serializers.py b/products/autoresearch/backend/presentation/views/serializers.py index 712deb36ba27..9f406fa81749 100644 --- a/products/autoresearch/backend/presentation/views/serializers.py +++ b/products/autoresearch/backend/presentation/views/serializers.py @@ -893,6 +893,12 @@ class TrainingRunSummarySerializer(serializers.Serializer): distillation = serializers.CharField( allow_blank=True, help_text="Agent's 1–2 sentence distillation of what this run learned. Empty if not provided." ) + report_notebook_short_id = serializers.CharField( + required=False, + default="", + allow_blank=True, + help_text="Short id of the report notebook the agent built for this run. Empty if there is none.", + ) @extend_schema_serializer(component_name="IterationTrail") @@ -1424,6 +1430,15 @@ class CompleteTrainingRunSerializer(serializers.Serializer): "dead-ends. Stored in the run summary as the cheapest thing the next run reads. Max 2000 characters." ), ) + report_notebook_short_id = serializers.CharField( + required=False, + allow_blank=True, + default="", + help_text=( + "Short id of the report notebook you built for this run. Stored in the run summary only if the " + "notebook exists in this project; an unknown id is dropped and does not fail the completion." + ), + ) # ── Feature materialization serializers ───────────────────────────────────── diff --git a/products/autoresearch/backend/presentation/views/views.py b/products/autoresearch/backend/presentation/views/views.py index 5149ef8bf31a..d70ba38d9a58 100644 --- a/products/autoresearch/backend/presentation/views/views.py +++ b/products/autoresearch/backend/presentation/views/views.py @@ -942,6 +942,7 @@ def complete(self, request: Request, *args: Any, **kwargs: Any) -> Response: model_explanation=data.get("model_explanation") or {}, recommended_next=data.get("recommended_next") or "", distillation=data.get("distillation") or "", + report_notebook_short_id=data.get("report_notebook_short_id") or "", ) except TrainingRunNotFound: raise NotFound("Training run not found.") diff --git a/products/autoresearch/backend/training/AGENTS.md b/products/autoresearch/backend/training/AGENTS.md index 703e0d3790de..5cc60d1bcac7 100644 --- a/products/autoresearch/backend/training/AGENTS.md +++ b/products/autoresearch/backend/training/AGENTS.md @@ -13,6 +13,7 @@ The other half is `../inference/`, which consumes what this package produces and The real path. `run_training()` creates the `AutoresearchTrainingRun` (status `RUNNING`) and fires `Task.create_and_run()` with `internal=True` and no repository, so the run shows up as an internal Task rather than in the normal Tasks list. The brief carries user-authored text, so the sandbox token holds only `TRAINING_MCP_SCOPES` (the `execute-sql` reads, the autoresearch scopes, and `user:read`, which the PostHog MCP server needs to start a session), and an empty connector allowlist keeps the team's shared MCP connectors out of the sandbox. `build_agent_description()` assembles the agent's brief — the target, the horizon, the population, and the contract for the bundle it must author. + When the `autoresearch-report-notebook` flag is on for the launching user, the token also holds `REPORT_NOTEBOOK_MCP_SCOPES` and the brief adds a Finalize step: the agent builds one report notebook from the `system.autoresearch_*` tables and passes its `short_id` to complete. `report.md` stays required either way. The agent drives the rest _itself_ through the `autoresearch-*` MCP tools: it records each iteration, uploads the bundle, and calls complete. Nothing polls it. - `stub.py` `run_stub_training()` — a hand-authored champion recipe with universal engagement features (event counts, distinct event types, days since first seen) that apply to any team and any target. @@ -27,6 +28,7 @@ The other half is `../inference/`, which consumes what this package produces and `_detect_uploaded_bundle()` decides whether the new model gets an `artifact_prefix` (bundle path) or only a recorded recipe (legacy path). The bundle is written once per run, so a losing iteration can overwrite it: the uploaded `features.sql` must match the `feature_sql` recorded by the selected iteration, whitespace aside, or promotion raises rather than publishing a champion whose recipe and score describe other code. `complete_training_run()` reads the bundle and enters the run's `team_scope()` before it opens the transaction, because the `TaskRun` safety net calls it from a worker thread with no request scope, and object-storage calls must not run under the row lock. + The agent's `report_notebook_short_id` goes into the run summary only if that notebook exists in the run's team. A bad id or a failed check stores an empty value and never fails completion. Only a promoted model is fitted. A challenger's `model.pkl` would never be read, because inference serves the champion and no path promotes a challenger row later. - `artifacts.py` Object storage for the bundle: `features.sql`, `train.py`, `predict.py`, plus the fitted `model.pkl` written at completion. diff --git a/products/autoresearch/backend/training/promotion.py b/products/autoresearch/backend/training/promotion.py index cf43c70feed5..e83a065b2f90 100644 --- a/products/autoresearch/backend/training/promotion.py +++ b/products/autoresearch/backend/training/promotion.py @@ -39,6 +39,7 @@ ) from products.autoresearch.backend.training import artifacts from products.autoresearch.backend.training.recipe_validation import RecipeValidationError, validate_model_class +from products.notebooks.backend.facade import api as notebooks_facade logger = structlog.get_logger(__name__) @@ -166,6 +167,7 @@ def _build_run_summary( champion_model_class: str, recommended_next: str, distillation: str, + report_notebook_short_id: str, ) -> dict[str, Any]: """Tier-1 cross-run memory: backend derives the structural facts; the agent supplies the two judgment fields (recommended_next, distillation). Read back by a new run before it iterates.""" @@ -186,6 +188,7 @@ def _build_run_summary( "dead_ends": [_summary_item(it) for it in dead_ends], "recommended_next": recommended_next or "", "distillation": distillation or "", + "report_notebook_short_id": report_notebook_short_id, } @@ -305,6 +308,7 @@ def complete_training_run( model_explanation: dict[str, Any] | None = None, recommended_next: str = "", distillation: str = "", + report_notebook_short_id: str = "", ) -> dict[str, Any]: """Finalize a run: pick the best iteration, decide champion vs challenger, persist the model.""" # The TaskRun safety net calls this from a worker thread, where no request has set a @@ -323,9 +327,28 @@ def complete_training_run( model_explanation=model_explanation, recommended_next=recommended_next, distillation=distillation, + report_notebook_short_id=_verified_report_notebook(current, report_notebook_short_id), ) +def _verified_report_notebook(training_run: AutoresearchTrainingRun, short_id: str) -> str: + """ + The agent's notebook short id if that notebook exists in the run's team, else "". + The model result matters more than the report, so a bad id or a failed check never fails completion. + """ + short_id = (short_id or "").strip() + if not short_id: + return "" + try: + if notebooks_facade.notebook_exists(training_run.team_id, short_id, include_deleted=False): + return short_id + except Exception: + logger.exception("autoresearch_report_notebook_check_failed", training_run_id=str(training_run.pk)) + return "" + logger.warning("autoresearch_report_notebook_not_found", training_run_id=str(training_run.pk)) + return "" + + def _activate_pipeline(pipeline: AutoresearchPipeline) -> None: """A pipeline with its first champion goes live: flip Draft/Bootstrapping -> Running so the daily coordinator starts scoring it. Mirrors the stub path (stub_training); pause/resume @@ -371,6 +394,7 @@ def _finalize_under_lock( model_explanation: dict[str, Any] | None, recommended_next: str, distillation: str, + report_notebook_short_id: str, ) -> dict[str, Any]: # Re-fetch under lock and re-check status inside the transaction. Both callers (the # complete API action and the TaskRun post_save safety net) guard on status outside @@ -468,6 +492,7 @@ def _finalize_under_lock( champion_model_class=_serving_model_class(promoted=promoted, model=model, incumbent=current), recommended_next=recommended_next, distillation=distillation, + report_notebook_short_id=report_notebook_short_id, ) training_run.save(update_fields=["status", "iteration_count", "best_holdout_score", "summary", "completed_at"]) diff --git a/products/autoresearch/backend/training/runner.py b/products/autoresearch/backend/training/runner.py index ba4293bcf27f..02f64872bdea 100644 --- a/products/autoresearch/backend/training/runner.py +++ b/products/autoresearch/backend/training/runner.py @@ -33,8 +33,10 @@ from posthog.hogql.property import action_to_expr from posthog.dataclasses import frozen +from posthog.models.user import User from products.actions.backend.models.action import Action +from products.autoresearch.backend.access import has_report_notebook_access from products.autoresearch.backend.dataset.labeling import TrainingSample, build_target_condition from products.autoresearch.backend.inference.sandbox import _resolve_acting_user, measure_training_sample from products.autoresearch.backend.models import AutoresearchPipeline, AutoresearchSuggestion, AutoresearchTrainingRun @@ -73,6 +75,11 @@ # The brief carries user-authored text, so the token grants nothing beyond that. TRAINING_MCP_SCOPES = ["query:read", "insight:read", "user:read", "autoresearch:read", "autoresearch:write"] +# Added only when the report notebook flag is on for the user who starts the run. +# notebook:write also exposes notebooks-partial-update and notebooks-destroy, so the brief +# limits the agent to the notebook it creates in this run. +REPORT_NOTEBOOK_MCP_SCOPES = ["notebook:read", "notebook:write"] + # Task.title is a 255-character column, and a pipeline name and target event can each # take all of it. _TASK_TITLE_MAX_CHARS = 255 @@ -141,12 +148,62 @@ def _describe_training_sample(sample: TrainingSample | None) -> str: return clause +def _report_notebook_step(pipeline: AutoresearchPipeline, *, training_run_id: str, today_iso: str) -> str: + """The Finalize step that builds the report notebook, indented to sit inside the brief.""" + step = textwrap.dedent(f""" + 3. **Build the report notebook** — a live copy of the report whose numbers come from SQL + cells, so a reader can check them and re-run them after scoring. `report.md` stays the + fallback: write it first, whatever happens in this step. + + Do this step only if `notebooks-create-markdown` and `notebooks-add-cell` are in your + tool list. If they are not, skip to the next step. + + Create exactly ONE notebook with `notebooks-create-markdown`. Title it + ` · model report · {today_iso}`, where the pipeline name is + {_wrap_untrusted(pipeline.name)}. Change and run only this notebook. Never update, + delete, or run any other notebook. + + Build it in this order, with markdown prose between the cells: + - **TL;DR** and **What it predicts** — the same content as `report.md`. + - **How training went** — a SQL cell over `system.autoresearch_iterations` where + `training_run_id = '{training_run_id}'`, then a Python cell that plots holdout AUC by + iteration and marks kept and discarded iterations. + - **How well it works** — a SQL cell over `system.autoresearch_models` where + `pipeline_id = '{pipeline.pk}'`: role, holdout AUC, realized AUC, calibration error, + and lift@10/@20 from `metrics` when present. Explain them in plain words. + - **What drives it** — a Python cell that charts the feature importances and direction + in `model_explanation` of this run's model row + (`source_training_run_id = '{training_run_id}'`), then prose on the intuition behind + each top feature. + - **Live performance** — a SQL cell over `events` where + `event = 'autoresearch_prediction'` and + `properties.$autoresearch_pipeline_id = '{pipeline.pk}'`, then Python cells for the + score histogram and the realized vs predicted rate by decile. A new model has no + predictions yet, so these cells must handle an empty result: print a clear message + such as "No predictions yet. Re-run after the first scoring run." and do not fail. + - **How it was built** and **Caveats and recommended use** — prose. + + Rules for every cell: + - Every number comes from a SQL cell. Do not type metrics into Python or prose tables. + - Python cells work only on the dataframes of earlier cells. No network access: no + `requests`, `urllib`, `http`, `socket`, or `subprocess`. No file reads or writes, + and no package installs. + - One figure per Python cell. The kernel keeps at most 8 figures and about 3 MB of + images per cell. + - Do not call `notebooks-configure-compute`. Use the default kernel. + - Run each cell. If a cell fails, fix it or delete it. Never leave a failed cell. + + Keep the notebook's `short_id` for the next step.""") + return textwrap.indent(step, " " * 8) + + def build_agent_description( pipeline: AutoresearchPipeline, iteration_budget: int, training_run_id: str, pending_suggestions: list[AutoresearchSuggestion] | None = None, training_sample: TrainingSample | None = None, + report_notebook: bool = False, ) -> str: """Build the Claude Code agent prompt for the autoresearch training loop.""" pop_clause = "" @@ -174,6 +231,16 @@ def build_agent_description( today_iso = date.today().isoformat() min_iters = min(3, iteration_budget) target = _describe_target(pipeline) + complete_step = 3 + notebook_step = "" + notebook_field = "" + if report_notebook: + complete_step = 4 + notebook_step = _report_notebook_step(pipeline, training_run_id=training_run_id, today_iso=today_iso) + notebook_field = ( + "\n - `report_notebook_short_id`: the `short_id` of the notebook from step 3. Omit it\n" + " if you skipped step 3 or the notebook does not exist." + ) prompt = textwrap.dedent(f""" # PostHog Autoresearch Agent @@ -530,14 +597,14 @@ def load_xy(fpath, lpath): Add a calibration line (predicted vs realized rate) if it aids the story. Where a chart would be overkill (or mermaid can't express it), fall back to compact ASCII/unicode bar charts inline — they render in any Markdown surface. Use plain GFM tables for the metrics - block. If a user suggestion asks for a particular audience or emphasis, honor it. - 3. Call `autoresearch-training-runs-complete-create` with `pipeline_id = "{pipeline.pk}"` + block. If a user suggestion asks for a particular audience or emphasis, honor it.{notebook_step} + {complete_step}. Call `autoresearch-training-runs-complete-create` with `pipeline_id = "{pipeline.pk}"` and `id = "{training_run_id}"`. The backend picks the best iteration, decides champion vs challenger, and attaches your uploaded bundle as the model's artifact. Also pass two short fields that become this run's learning memory for the NEXT run: - `distillation`: 1–2 sentences on what this run learned — the winning signal, the key transform, the dead-ends. This is the cheapest thing the next run reads. - - `recommended_next`: concretely what a future run should try next given what you found. + - `recommended_next`: concretely what a future run should try next given what you found.{notebook_field} The backend derives the rest of the summary (the kept ladder and dead-ends) from your recorded iterations, so keep these two fields to judgment only — do not restate the ladder. @@ -604,6 +671,16 @@ def _training_sample_for_brief(pipeline: AutoresearchPipeline) -> TrainingSample return None +def _report_notebook_enabled(pipeline: AutoresearchPipeline, *, user_id: int) -> bool: + """A flag check that fails leaves the notebook out rather than failing the launch.""" + try: + user = User.objects.filter(pk=user_id).first() + return has_report_notebook_access(user, team_id=pipeline.team_id) + except Exception: + logger.warning("autoresearch_report_notebook_flag_check_failed", pipeline_id=str(pipeline.pk), exc_info=True) + return False + + def run_training( pipeline: AutoresearchPipeline, iteration_budget: int, @@ -625,6 +702,9 @@ def run_training( # Completion fits the champion as the pipeline's creator, so a creator who has left # would consume the paid run and leave a champion that no scoring run can load. _resolve_acting_user(team=pipeline.team, pipeline=pipeline, user=None) + # The MCP token belongs to user_id, so the flag is evaluated for the same user. + report_notebook = _report_notebook_enabled(pipeline, user_id=user_id) + mcp_scopes = TRAINING_MCP_SCOPES + REPORT_NOTEBOOK_MCP_SCOPES if report_notebook else TRAINING_MCP_SCOPES # Every materialization labels through this condition, so a target it refuses (a deleted # action, or one with no steps) would fail the whole paid run. build_target_condition( @@ -662,6 +742,7 @@ def run_training( training_run_id=str(training_run.id), pending_suggestions=pending_suggestions or None, training_sample=_training_sample_for_brief(pipeline), + report_notebook=report_notebook, ) title = f"[autoresearch] {pipeline.name}: learn to predict '{pipeline.target_event}'" @@ -675,7 +756,7 @@ def run_training( create_pr=False, mode="background", internal=True, - posthog_mcp_scopes=TRAINING_MCP_SCOPES, + posthog_mcp_scopes=mcp_scopes, # The autoresearch image is the agent-capable base plus pandas/numpy/ # scikit-learn/pyarrow at system site. The base image lacks the ML libs; the # notebook image has the libs but cannot host the agent server — only this diff --git a/products/autoresearch/backend/training/test_promotion.py b/products/autoresearch/backend/training/test_promotion.py index a0542590d9ca..aee4e2399ec0 100644 --- a/products/autoresearch/backend/training/test_promotion.py +++ b/products/autoresearch/backend/training/test_promotion.py @@ -11,6 +11,7 @@ from parameterized import parameterized from posthog.models.scoping import unscoped +from posthog.models.team import Team from posthog.storage.object_storage import ObjectStorageError from products.autoresearch.backend.models import ( @@ -23,6 +24,7 @@ from products.autoresearch.backend.training.artifacts import ArtifactBundle, InvalidArtifactContent, PartialBundle from products.autoresearch.backend.training.promotion import PromotionError, complete_training_run from products.autoresearch.backend.training.stub import run_stub_training +from products.notebooks.backend.facade import api as notebooks_facade ANCHORED_FEATURE_SQL = "SELECT a.person_id AS distinct_id, count() AS c FROM {anchors} a GROUP BY a.person_id" _DEFAULT_PARAMS = object() @@ -328,6 +330,23 @@ def test_bundle_sql_the_fit_cannot_run_blocks_promotion(self, _name, features_sq assert not AutoresearchModel.objects.filter(pipeline=self.pipeline).exists() + @parameterized.expand([("own_team", "own", True), ("other_team", "other", False), ("missing", "none", False)]) + def test_report_notebook_is_linked_only_when_it_exists_in_the_run_team(self, _name, owner, linked): + if owner == "none": + short_id = "doesnotexist" + else: + team_id = self.team.pk if owner == "own" else Team.objects.create(organization=self.organization).pk + short_id = notebooks_facade.create_notebook(team_id, title="Report", content=None).short_id + run = self._run() + self._iteration(run, number=0, holdout=0.8) + + result = complete_training_run(run, report_notebook_short_id=short_id) + + assert result["promoted"] is True + run.refresh_from_db() + assert run.status == AutoresearchTrainingRun.Status.COMPLETED + assert run.summary["report_notebook_short_id"] == (short_id if linked else "") + def test_completion_runs_without_an_ambient_team_scope(self): # The TaskRun safety net finalizes a run from a worker thread, where no request has # set a scope. Every read in promotion goes through a fail-closed manager. diff --git a/products/autoresearch/backend/training/test_training.py b/products/autoresearch/backend/training/test_training.py index 3434935ddc89..775b0ac6cb31 100644 --- a/products/autoresearch/backend/training/test_training.py +++ b/products/autoresearch/backend/training/test_training.py @@ -15,6 +15,7 @@ from products.autoresearch.backend.models import AutoresearchPipeline, AutoresearchSuggestion, AutoresearchTrainingRun from products.autoresearch.backend.testing import TeamScopedTestMixin from products.autoresearch.backend.training.runner import ( + REPORT_NOTEBOOK_MCP_SCOPES, TRAINING_MCP_SCOPES, UNTRUSTED_DATA_TAG, build_agent_description, @@ -35,9 +36,12 @@ def _make_pipeline(self) -> AutoresearchPipeline: iteration_budget_remaining=10, ) - def test_prompt_renders_without_unresolved_placeholders(self) -> None: + @parameterized.expand([("without_notebook", False), ("with_notebook", True)]) + def test_prompt_renders_without_unresolved_placeholders(self, _name: str, report_notebook: bool) -> None: pipeline = self._make_pipeline() - prompt = build_agent_description(pipeline=pipeline, iteration_budget=5, training_run_id="run-123") + prompt = build_agent_description( + pipeline=pipeline, iteration_budget=5, training_run_id="run-123", report_notebook=report_notebook + ) # `{anchors}` and `{lookback_days}` are intentional — they are documented # placeholders the agent is taught to use inside its own SQL, and `{init}` # is the literal mermaid `%%{init}%%` directive the report section forbids. @@ -96,13 +100,19 @@ def test_prompt_drives_artifact_bundle_flow_not_set_output(self) -> None: assert "set_output/" not in prompt assert "recipe.json" not in prompt - def test_prompt_instructs_report_md(self) -> None: + @parameterized.expand([("without_notebook", False), ("with_notebook", True)]) + def test_prompt_instructs_report_md(self, _name: str, report_notebook: bool) -> None: pipeline = self._make_pipeline() - prompt = build_agent_description(pipeline=pipeline, iteration_budget=5, training_run_id="run-123") + prompt = build_agent_description( + pipeline=pipeline, iteration_budget=5, training_run_id="run-123", report_notebook=report_notebook + ) # The agent must author a portable report.md, uploaded like the bundle files, with charts. assert "report.md" in prompt assert "mermaid" in prompt assert "autoresearch-training-runs-artifacts-upload-create" in prompt + assert ("notebooks-create-markdown" in prompt) is report_notebook + assert ("report_notebook_short_id" in prompt) is report_notebook + assert prompt.index("report.md") < prompt.index("autoresearch-training-runs-complete-create") def test_prompt_excludes_autoresearch_feedback_events(self) -> None: pipeline = self._make_pipeline() @@ -172,13 +182,26 @@ def _dispatched(self, facade: MagicMock) -> None: facade.create_and_run_task.return_value = MagicMock(task_id=uuid.uuid4(), latest_run=MagicMock(id=uuid.uuid4())) facade.task_run_is_terminal.return_value = False - def test_dispatch_grants_only_training_scopes_and_stamps_the_run(self, facade: MagicMock) -> None: + @parameterized.expand( + [ + ("flag_off", False, TRAINING_MCP_SCOPES), + ("flag_on", True, TRAINING_MCP_SCOPES + REPORT_NOTEBOOK_MCP_SCOPES), + ] + ) + def test_dispatch_grants_only_training_scopes_and_stamps_the_run( + self, facade: MagicMock, _name: str, flag_on: bool, expected_scopes: list[str] + ) -> None: self._dispatched(facade) - training_run = run_training(self.pipeline, iteration_budget=5, user_id=self.user.id) + with patch( + "products.autoresearch.backend.training.runner.has_report_notebook_access", return_value=flag_on + ) as flag: + training_run = run_training(self.pipeline, iteration_budget=5, user_id=self.user.id) + assert flag.call_args.args[0] == self.user kwargs = facade.create_and_run_task.call_args.kwargs - assert kwargs["posthog_mcp_scopes"] == TRAINING_MCP_SCOPES + assert kwargs["posthog_mcp_scopes"] == expected_scopes + assert ("notebooks-create-markdown" in kwargs["description"]) is flag_on assert "user:read" in kwargs["posthog_mcp_scopes"] assert kwargs["extra_run_state"] == { "autoresearch_training_run_id": str(training_run.id), diff --git a/products/autoresearch/frontend/generated/api.schemas.ts b/products/autoresearch/frontend/generated/api.schemas.ts index 2f4645ea3176..368ca65827e3 100644 --- a/products/autoresearch/frontend/generated/api.schemas.ts +++ b/products/autoresearch/frontend/generated/api.schemas.ts @@ -674,6 +674,8 @@ export interface TrainingRunSummaryApi { recommended_next: string /** Agent's 1–2 sentence distillation of what this run learned. Empty if not provided. */ distillation: string + /** Short id of the report notebook the agent built for this run. Empty if there is none. */ + report_notebook_short_id?: string } /** @@ -917,6 +919,8 @@ export interface CompleteTrainingRunApi { * @maxLength 2000 */ distillation?: string + /** Short id of the report notebook you built for this run. Stored in the run summary only if the notebook exists in this project; an unknown id is dropped and does not fail the completion. */ + report_notebook_short_id?: string } export type RecordIterationApiRecipeSnapshotFeatureTransformsItem = { [key: string]: unknown } diff --git a/products/autoresearch/frontend/generated/api.zod.ts b/products/autoresearch/frontend/generated/api.zod.ts index 1d4df754342f..751f48e3d8b3 100644 --- a/products/autoresearch/frontend/generated/api.zod.ts +++ b/products/autoresearch/frontend/generated/api.zod.ts @@ -248,6 +248,8 @@ export const autoresearchTrainingRunsCompleteCreateBodyRecommendedNextMax = 2000 export const autoresearchTrainingRunsCompleteCreateBodyDistillationDefault = `` export const autoresearchTrainingRunsCompleteCreateBodyDistillationMax = 2000 +export const autoresearchTrainingRunsCompleteCreateBodyReportNotebookShortIdDefault = `` + export const AutoresearchTrainingRunsCompleteCreateBody = /* @__PURE__ */ zod .object({ best_iteration_id: zod @@ -274,6 +276,12 @@ export const AutoresearchTrainingRunsCompleteCreateBody = /* @__PURE__ */ zod .describe( 'A 1–2 sentence distillation of what this run learned — the winning signal, the key transform, the dead-ends. Stored in the run summary as the cheapest thing the next run reads. Max 2000 characters.' ), + report_notebook_short_id: zod + .string() + .default(autoresearchTrainingRunsCompleteCreateBodyReportNotebookShortIdDefault) + .describe( + 'Short id of the report notebook you built for this run. Stored in the run summary only if the notebook exists in this project; an unknown id is dropped and does not fail the completion.' + ), }) .describe('Input for finalizing a training run. The backend selects\/promotes the champion.') diff --git a/products/autoresearch/mcp/tools.yaml b/products/autoresearch/mcp/tools.yaml index 21a898760612..87c83c366bc2 100644 --- a/products/autoresearch/mcp/tools.yaml +++ b/products/autoresearch/mcp/tools.yaml @@ -507,7 +507,7 @@ tools: model card. Also pass distillation (1–2 sentences on what this run learned) and recommended_next (what a future run should try next) — these are stored in the run summary and read back by the next run during orientation; the backend derives the rest of the summary (kept ladder, dead-ends) from your recorded - iterations. + iterations. If you built a report notebook for this run, pass its short_id as report_notebook_short_id. feature_flag: autoresearch response: include: diff --git a/services/mcp/schema/generated-tool-definitions.json b/services/mcp/schema/generated-tool-definitions.json index c830e34ff9fd..50bbc2c4c927 100644 --- a/services/mcp/schema/generated-tool-definitions.json +++ b/services/mcp/schema/generated-tool-definitions.json @@ -1330,7 +1330,7 @@ "feature_flag": "autoresearch" }, "autoresearch-training-runs-complete-create": { - "description": "Finalize a training run. The backend selects the kept iteration with the highest holdout_score. best_iteration_id only breaks a tie at that top score and never overrides the ranking, so upload the bundle of the highest-scoring iteration. The backend then decides champion vs challenger via the promotion ladder (cold-start preliminary, anti-thrash margin for an existing champion), and persists the model. Agents cannot set the champion directly — promotion is server-only. Call autoresearch-models-list afterwards to see the resulting champion/challenger. Optionally pass model_explanation (top features and directionality) for the model card. Also pass distillation (1–2 sentences on what this run learned) and recommended_next (what a future run should try next) — these are stored in the run summary and read back by the next run during orientation; the backend derives the rest of the summary (kept ladder, dead-ends) from your recorded iterations.", + "description": "Finalize a training run. The backend selects the kept iteration with the highest holdout_score. best_iteration_id only breaks a tie at that top score and never overrides the ranking, so upload the bundle of the highest-scoring iteration. The backend then decides champion vs challenger via the promotion ladder (cold-start preliminary, anti-thrash margin for an existing champion), and persists the model. Agents cannot set the champion directly — promotion is server-only. Call autoresearch-models-list afterwards to see the resulting champion/challenger. Optionally pass model_explanation (top features and directionality) for the model card. Also pass distillation (1–2 sentences on what this run learned) and recommended_next (what a future run should try next) — these are stored in the run summary and read back by the next run during orientation; the backend derives the rest of the summary (kept ladder, dead-ends) from your recorded iterations. If you built a report notebook for this run, pass its short_id as report_notebook_short_id.", "category": "Autoresearch", "feature": "autoresearch", "summary": "Complete a training run", diff --git a/services/mcp/schema/tool-definitions-all.json b/services/mcp/schema/tool-definitions-all.json index 523e749a6de1..a70531757fc3 100644 --- a/services/mcp/schema/tool-definitions-all.json +++ b/services/mcp/schema/tool-definitions-all.json @@ -1345,7 +1345,7 @@ "feature_flag": "autoresearch" }, "autoresearch-training-runs-complete-create": { - "description": "Finalize a training run. The backend selects the kept iteration with the highest holdout_score. best_iteration_id only breaks a tie at that top score and never overrides the ranking, so upload the bundle of the highest-scoring iteration. The backend then decides champion vs challenger via the promotion ladder (cold-start preliminary, anti-thrash margin for an existing champion), and persists the model. Agents cannot set the champion directly — promotion is server-only. Call autoresearch-models-list afterwards to see the resulting champion/challenger. Optionally pass model_explanation (top features and directionality) for the model card. Also pass distillation (1–2 sentences on what this run learned) and recommended_next (what a future run should try next) — these are stored in the run summary and read back by the next run during orientation; the backend derives the rest of the summary (kept ladder, dead-ends) from your recorded iterations.", + "description": "Finalize a training run. The backend selects the kept iteration with the highest holdout_score. best_iteration_id only breaks a tie at that top score and never overrides the ranking, so upload the bundle of the highest-scoring iteration. The backend then decides champion vs challenger via the promotion ladder (cold-start preliminary, anti-thrash margin for an existing champion), and persists the model. Agents cannot set the champion directly — promotion is server-only. Call autoresearch-models-list afterwards to see the resulting champion/challenger. Optionally pass model_explanation (top features and directionality) for the model card. Also pass distillation (1–2 sentences on what this run learned) and recommended_next (what a future run should try next) — these are stored in the run summary and read back by the next run during orientation; the backend derives the rest of the summary (kept ladder, dead-ends) from your recorded iterations. If you built a report notebook for this run, pass its short_id as report_notebook_short_id.", "category": "Autoresearch", "feature": "autoresearch", "summary": "Complete a training run", diff --git a/services/mcp/src/api/generated.ts b/services/mcp/src/api/generated.ts index 0e45c473cde8..2092d64c2a24 100644 --- a/services/mcp/src/api/generated.ts +++ b/services/mcp/src/api/generated.ts @@ -13686,6 +13686,8 @@ export namespace Schemas { recommended_next: string; /** Agent's 1–2 sentence distillation of what this run learned. Empty if not provided. */ distillation: string; + /** Short id of the report notebook the agent built for this run. Empty if there is none. */ + report_notebook_short_id?: string; } /** @@ -23555,6 +23557,8 @@ export namespace Schemas { * @maxLength 2000 */ distillation?: string; + /** Short id of the report notebook you built for this run. Stored in the run summary only if the notebook exists in this project; an unknown id is dropped and does not fail the completion. */ + report_notebook_short_id?: string; } export interface ComposeTicket { diff --git a/services/mcp/src/generated/autoresearch/api.ts b/services/mcp/src/generated/autoresearch/api.ts index 4a88cc4ce81c..9b46d1673d11 100644 --- a/services/mcp/src/generated/autoresearch/api.ts +++ b/services/mcp/src/generated/autoresearch/api.ts @@ -425,6 +425,8 @@ export const autoresearchTrainingRunsCompleteCreateBodyRecommendedNextMax = 2000 export const autoresearchTrainingRunsCompleteCreateBodyDistillationDefault = `` export const autoresearchTrainingRunsCompleteCreateBodyDistillationMax = 2000 +export const autoresearchTrainingRunsCompleteCreateBodyReportNotebookShortIdDefault = `` + export const AutoresearchTrainingRunsCompleteCreateBody = () => zod .object({ best_iteration_id: zod @@ -451,6 +453,12 @@ export const AutoresearchTrainingRunsCompleteCreateBody = () => zod .describe( 'A 1–2 sentence distillation of what this run learned — the winning signal, the key transform, the dead-ends. Stored in the run summary as the cheapest thing the next run reads. Max 2000 characters.' ), + report_notebook_short_id: zod + .string() + .default(autoresearchTrainingRunsCompleteCreateBodyReportNotebookShortIdDefault) + .describe( + 'Short id of the report notebook you built for this run. Stored in the run summary only if the notebook exists in this project; an unknown id is dropped and does not fail the completion.' + ), }) .describe('Input for finalizing a training run. The backend selects\/promotes the champion.') diff --git a/services/mcp/src/tools/generated/autoresearch.ts b/services/mcp/src/tools/generated/autoresearch.ts index ec707d4725a6..8b6198728d1c 100644 --- a/services/mcp/src/tools/generated/autoresearch.ts +++ b/services/mcp/src/tools/generated/autoresearch.ts @@ -595,6 +595,9 @@ const autoresearchTrainingRunsCompleteCreate = (): ToolBase< if (params.distillation !== undefined) { body['distillation'] = params.distillation } + if (params.report_notebook_short_id !== undefined) { + body['report_notebook_short_id'] = params.report_notebook_short_id + } const result = await context.api.request({ method: 'POST', path: `/api/projects/${encodeURIComponent(String(projectId))}/autoresearch/${encodeURIComponent(String(params.pipeline_id))}/training_runs/${encodeURIComponent(String(params.id))}/complete/`, diff --git a/tach.toml b/tach.toml index dd749ec1b356..814cea3f3f90 100644 --- a/tach.toml +++ b/tach.toml @@ -235,6 +235,7 @@ depends_on = [ "posthog", "products.actions", "products.feature_flags", + "products.notebooks", "products.tasks", ] layer = "modules" From 218e07597a20a07dd092eed2aa2312fa07915612 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojta=20Barto=C5=A1?= Date: Fri, 2 Oct 2026 16:57:40 +0200 Subject: [PATCH 40/48] feat(today): send posthog ai context in a hidden context block The Today home and report page now send their context to PostHog AI inside a block after the question. The web chat and PostHog Desktop hide that block from the person's message, so the chat shows only the question while the agent and the run log keep the full prompt. Co-Authored-By: Claude Opus 5.5 Generated-By: PostHog Desktop Task-Id: dd57cc57-2d63-424b-88ca-841341bf3ead --- .../scenes/project-homepage/today/todayAskPrompt.ts | 10 +++++++++- .../scenes/project-homepage/today/todayLogic.test.ts | 11 ++++++++++- .../packages/core/src/editor/injectedBlocks.test.ts | 11 +++++++++++ .../packages/core/src/editor/injectedBlocks.ts | 7 ++++++- .../features/canvas/components/channelFeedDisplay.ts | 2 +- products/tasks/frontend/spaces/spaceFeedPreview.ts | 2 ++ 6 files changed, 39 insertions(+), 4 deletions(-) diff --git a/frontend/src/scenes/project-homepage/today/todayAskPrompt.ts b/frontend/src/scenes/project-homepage/today/todayAskPrompt.ts index f803b33daf7b..dde1b2dfb8b0 100644 --- a/frontend/src/scenes/project-homepage/today/todayAskPrompt.ts +++ b/frontend/src/scenes/project-homepage/today/todayAskPrompt.ts @@ -108,10 +108,18 @@ function contextLines(context: Exclude): stri /** * The question with the Today page's context under it, as Markdown. PostHog AI reads the briefing and reports * through the MCP tools the context names, so the context holds ids rather than the full report text. + * + * The context goes in a `` block. The chat hides that block from the person's message (`INJECTED_TAGS` in + * spaceFeedPreview, and `injectedBlocks` in PostHog Desktop), so the chat shows only the question, but the agent and + * the run log keep the full prompt. */ export function todayAskPrompt(question: string, context: TodayAskContext): string { if (context.kind === 'none' || (context.kind === 'reports' && context.reports.length === 0)) { return question } - return [question, '', '---', '', contextHeading(context), '', ...contextLines(context)].join('\n') + // A literal tag in a report title would end the block early, and the rest would show in the chat. + const body = [contextHeading(context), '', ...contextLines(context)] + .join('\n') + .replace(/<(\/?)context\b/gi, '<\\$1context') + return [question, '', '', body, ''].join('\n') } diff --git a/frontend/src/scenes/project-homepage/today/todayLogic.test.ts b/frontend/src/scenes/project-homepage/today/todayLogic.test.ts index a8c1c6ecc5a2..d2a91ea04d6a 100644 --- a/frontend/src/scenes/project-homepage/today/todayLogic.test.ts +++ b/frontend/src/scenes/project-homepage/today/todayLogic.test.ts @@ -4,6 +4,7 @@ import { expectLogic } from 'kea-test-utils' import { useMocks } from '~/mocks/jest' import { initKeaTests } from '~/test/init' +import { userMessageDisplayText } from 'products/posthog_ai/frontend/utils/userMessageDisplay' import { makeReport } from 'products/signals/frontend/inbox/__mocks__/inboxMocks' import { SignalReport, SignalReportStatus } from 'products/signals/frontend/inbox/types' import type { BriefingApi, BriefingItemReportApi } from 'products/today/frontend/generated/api.schemas' @@ -157,6 +158,12 @@ describe('todayLogic', () => { report: makeReport({ id: 'r-6' }), expected: ['from the inbox report i am reading', '/inbox/reports/r-6)'], }, + { + shown: 'an open report with a context tag in its title', + hasBriefing: true, + report: makeReport({ id: 'r-7', title: 'Prompt leaks into the chat' }), + expected: ['[prompt leaks <\\/context> into the chat]('], + }, ])( 'sends PostHog AI the question with $shown as context', async ({ hasBriefing, report, current, sample, expected, absent }) => { @@ -181,7 +188,9 @@ describe('todayLogic', () => { logic.actions.setUseSampleData(false) const prompt = router.values.searchParams.ask as string - expect(prompt.startsWith('Why is signup broken?\n')).toBe(true) + // The chat hides the context block, so the person sees only their question. + expect(userMessageDisplayText(prompt)).toEqual('Why is signup broken?') + expect(prompt).toContain('\n\n') for (const text of expected) { expect(prompt.toLowerCase()).toContain(text.toLowerCase()) } diff --git a/products/desktop/packages/core/src/editor/injectedBlocks.test.ts b/products/desktop/packages/core/src/editor/injectedBlocks.test.ts index 831bd8ca6b6b..b4a992edecc8 100644 --- a/products/desktop/packages/core/src/editor/injectedBlocks.test.ts +++ b/products/desktop/packages/core/src/editor/injectedBlocks.test.ts @@ -114,6 +114,17 @@ describe("splitInjectedBlocks", () => { block: { kind: "posthog-context", body: TRUSTED, attrs: {} }, text: "what does this show", }, + { + name: "a context block a PostHog page attached after the question", + content: + "why is this happening\n\n\n#### Context from the Inbox report I am reading\n", + block: { + kind: "posthog-context", + body: "\n#### Context from the Inbox report I am reading\n", + attrs: {}, + }, + text: "why is this happening", + }, { name: "a Slack thread", content: diff --git a/products/desktop/packages/core/src/editor/injectedBlocks.ts b/products/desktop/packages/core/src/editor/injectedBlocks.ts index 95b770574b9c..a25a81002900 100644 --- a/products/desktop/packages/core/src/editor/injectedBlocks.ts +++ b/products/desktop/packages/core/src/editor/injectedBlocks.ts @@ -53,7 +53,12 @@ const INJECTED_BLOCK_SPECS: readonly InjectedBlockSpec[] = [ spec("canvas-instructions", [CANVAS_INSTRUCTIONS_TAG]), spec( "posthog-context", - ["posthog_trusted_context", "posthog_untrusted_context", "posthog_context"], + [ + "posthog_trusted_context", + "posthog_untrusted_context", + "posthog_context", + "context", + ], { keepTags: true }, ), spec("custom-instructions", [CUSTOM_INSTRUCTIONS_TAG], { diff --git a/products/desktop/packages/ui/src/features/canvas/components/channelFeedDisplay.ts b/products/desktop/packages/ui/src/features/canvas/components/channelFeedDisplay.ts index 53ffae410413..68b8ad901f02 100644 --- a/products/desktop/packages/ui/src/features/canvas/components/channelFeedDisplay.ts +++ b/products/desktop/packages/ui/src/features/canvas/components/channelFeedDisplay.ts @@ -5,7 +5,7 @@ import type { SpacePullRequest } from "@posthog/ui/features/canvas/components/wo import type { ChannelFeedSystemMessage } from "@posthog/ui/features/canvas/hooks/useChannelFeedMessages"; const incompleteContextBlock = - /<(?:channel_context|canvas_generation_instructions|posthog_trusted_context|posthog_untrusted_context|posthog_context|user_custom_instructions|onboarding_brief|slack_thread_context)\b[\s\S]*$/; + /<(?:channel_context|canvas_generation_instructions|posthog_trusted_context|posthog_untrusted_context|posthog_context|context|user_custom_instructions|onboarding_brief|slack_thread_context)\b[\s\S]*$/; export function stripContextBlocks(text: string): string { return stripInjectedBlocks(text) diff --git a/products/tasks/frontend/spaces/spaceFeedPreview.ts b/products/tasks/frontend/spaces/spaceFeedPreview.ts index f44c6752d69a..36496dfdc7f2 100644 --- a/products/tasks/frontend/spaces/spaceFeedPreview.ts +++ b/products/tasks/frontend/spaces/spaceFeedPreview.ts @@ -6,6 +6,8 @@ const INJECTED_TAGS = [ 'posthog_trusted_context', 'posthog_untrusted_context', 'posthog_context', + // Context a PostHog page attaches to a question, such as the Today home's briefing or report. + 'context', 'onboarding_brief', 'slack_thread_context', ] From 599c0e45e73c4acab7e9d753deb1595d35bce84a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojta=20Barto=C5=A1?= Date: Fri, 2 Oct 2026 16:57:43 +0200 Subject: [PATCH 41/48] feat(today): wrap the posthog ai context in posthog_context The web chat and PostHog Desktop already hide blocks, so the generic tag and its additions to the hidden-tag lists are no longer needed. The escape now covers all three PostHog context tag names, the same as defang in posthogContextBlock. Co-Authored-By: Claude Opus 5.5 Generated-By: PostHog Desktop Task-Id: dd57cc57-2d63-424b-88ca-841341bf3ead --- .../scenes/project-homepage/today/todayAskPrompt.ts | 13 +++++++------ .../project-homepage/today/todayLogic.test.ts | 6 +++--- .../packages/core/src/editor/injectedBlocks.test.ts | 11 ----------- .../packages/core/src/editor/injectedBlocks.ts | 7 +------ .../canvas/components/channelFeedDisplay.ts | 2 +- products/tasks/frontend/spaces/spaceFeedPreview.ts | 2 -- 6 files changed, 12 insertions(+), 29 deletions(-) diff --git a/frontend/src/scenes/project-homepage/today/todayAskPrompt.ts b/frontend/src/scenes/project-homepage/today/todayAskPrompt.ts index dde1b2dfb8b0..d382849f47b7 100644 --- a/frontend/src/scenes/project-homepage/today/todayAskPrompt.ts +++ b/frontend/src/scenes/project-homepage/today/todayAskPrompt.ts @@ -109,17 +109,18 @@ function contextLines(context: Exclude): stri * The question with the Today page's context under it, as Markdown. PostHog AI reads the briefing and reports * through the MCP tools the context names, so the context holds ids rather than the full report text. * - * The context goes in a `` block. The chat hides that block from the person's message (`INJECTED_TAGS` in - * spaceFeedPreview, and `injectedBlocks` in PostHog Desktop), so the chat shows only the question, but the agent and - * the run log keep the full prompt. + * The context goes in a `` block. The chat hides that block from the person's message + * (`INJECTED_TAGS` in spaceFeedPreview, and `injectedBlocks` in PostHog Desktop), so the chat shows only the question, + * but the agent and the run log keep the full prompt. */ export function todayAskPrompt(question: string, context: TodayAskContext): string { if (context.kind === 'none' || (context.kind === 'reports' && context.reports.length === 0)) { return question } - // A literal tag in a report title would end the block early, and the rest would show in the chat. + // A literal context tag in a report title would end the block early or fake a trusted block. Same escape as + // `defang` in posthogContextBlock. const body = [contextHeading(context), '', ...contextLines(context)] .join('\n') - .replace(/<(\/?)context\b/gi, '<\\$1context') - return [question, '', '', body, ''].join('\n') + .replace(/<(\/?)(posthog_(?:(?:un)?trusted_)?context)/g, '<\\$1$2') + return [question, '', '', body, ''].join('\n') } diff --git a/frontend/src/scenes/project-homepage/today/todayLogic.test.ts b/frontend/src/scenes/project-homepage/today/todayLogic.test.ts index d2a91ea04d6a..08fcd93a34f6 100644 --- a/frontend/src/scenes/project-homepage/today/todayLogic.test.ts +++ b/frontend/src/scenes/project-homepage/today/todayLogic.test.ts @@ -161,8 +161,8 @@ describe('todayLogic', () => { { shown: 'an open report with a context tag in its title', hasBriefing: true, - report: makeReport({ id: 'r-7', title: 'Prompt leaks into the chat' }), - expected: ['[prompt leaks <\\/context> into the chat]('], + report: makeReport({ id: 'r-7', title: 'Prompt leaks into the chat' }), + expected: ['[prompt leaks <\\/posthog_context> into the chat]('], }, ])( 'sends PostHog AI the question with $shown as context', @@ -190,7 +190,7 @@ describe('todayLogic', () => { const prompt = router.values.searchParams.ask as string // The chat hides the context block, so the person sees only their question. expect(userMessageDisplayText(prompt)).toEqual('Why is signup broken?') - expect(prompt).toContain('\n\n') + expect(prompt).toContain('\n\n') for (const text of expected) { expect(prompt.toLowerCase()).toContain(text.toLowerCase()) } diff --git a/products/desktop/packages/core/src/editor/injectedBlocks.test.ts b/products/desktop/packages/core/src/editor/injectedBlocks.test.ts index b4a992edecc8..831bd8ca6b6b 100644 --- a/products/desktop/packages/core/src/editor/injectedBlocks.test.ts +++ b/products/desktop/packages/core/src/editor/injectedBlocks.test.ts @@ -114,17 +114,6 @@ describe("splitInjectedBlocks", () => { block: { kind: "posthog-context", body: TRUSTED, attrs: {} }, text: "what does this show", }, - { - name: "a context block a PostHog page attached after the question", - content: - "why is this happening\n\n\n#### Context from the Inbox report I am reading\n", - block: { - kind: "posthog-context", - body: "\n#### Context from the Inbox report I am reading\n", - attrs: {}, - }, - text: "why is this happening", - }, { name: "a Slack thread", content: diff --git a/products/desktop/packages/core/src/editor/injectedBlocks.ts b/products/desktop/packages/core/src/editor/injectedBlocks.ts index a25a81002900..95b770574b9c 100644 --- a/products/desktop/packages/core/src/editor/injectedBlocks.ts +++ b/products/desktop/packages/core/src/editor/injectedBlocks.ts @@ -53,12 +53,7 @@ const INJECTED_BLOCK_SPECS: readonly InjectedBlockSpec[] = [ spec("canvas-instructions", [CANVAS_INSTRUCTIONS_TAG]), spec( "posthog-context", - [ - "posthog_trusted_context", - "posthog_untrusted_context", - "posthog_context", - "context", - ], + ["posthog_trusted_context", "posthog_untrusted_context", "posthog_context"], { keepTags: true }, ), spec("custom-instructions", [CUSTOM_INSTRUCTIONS_TAG], { diff --git a/products/desktop/packages/ui/src/features/canvas/components/channelFeedDisplay.ts b/products/desktop/packages/ui/src/features/canvas/components/channelFeedDisplay.ts index 68b8ad901f02..53ffae410413 100644 --- a/products/desktop/packages/ui/src/features/canvas/components/channelFeedDisplay.ts +++ b/products/desktop/packages/ui/src/features/canvas/components/channelFeedDisplay.ts @@ -5,7 +5,7 @@ import type { SpacePullRequest } from "@posthog/ui/features/canvas/components/wo import type { ChannelFeedSystemMessage } from "@posthog/ui/features/canvas/hooks/useChannelFeedMessages"; const incompleteContextBlock = - /<(?:channel_context|canvas_generation_instructions|posthog_trusted_context|posthog_untrusted_context|posthog_context|context|user_custom_instructions|onboarding_brief|slack_thread_context)\b[\s\S]*$/; + /<(?:channel_context|canvas_generation_instructions|posthog_trusted_context|posthog_untrusted_context|posthog_context|user_custom_instructions|onboarding_brief|slack_thread_context)\b[\s\S]*$/; export function stripContextBlocks(text: string): string { return stripInjectedBlocks(text) diff --git a/products/tasks/frontend/spaces/spaceFeedPreview.ts b/products/tasks/frontend/spaces/spaceFeedPreview.ts index 36496dfdc7f2..f44c6752d69a 100644 --- a/products/tasks/frontend/spaces/spaceFeedPreview.ts +++ b/products/tasks/frontend/spaces/spaceFeedPreview.ts @@ -6,8 +6,6 @@ const INJECTED_TAGS = [ 'posthog_trusted_context', 'posthog_untrusted_context', 'posthog_context', - // Context a PostHog page attaches to a question, such as the Today home's briefing or report. - 'context', 'onboarding_brief', 'slack_thread_context', ] From 28d01ecf7cd406001af92ba7d8bf132c81edf07d Mon Sep 17 00:00:00 2001 From: Radu Raicea Date: Fri, 2 Oct 2026 10:56:33 -0400 Subject: [PATCH 42/48] fix(aio): release stream slots before blocked reads finish --- .../internal/ai-observability-judge-inputs.md | 2 + ee/hogai/utils/asgi.py | 24 +++++--- ee/hogai/utils/test/test_asgi.py | 9 ++- posthog/api/test/test_streaming.py | 56 +++++++++++++------ 4 files changed, 65 insertions(+), 26 deletions(-) diff --git a/docs/internal/ai-observability-judge-inputs.md b/docs/internal/ai-observability-judge-inputs.md index 01ca7c7629e6..cf9e667636ca 100644 --- a/docs/internal/ai-observability-judge-inputs.md +++ b/docs/internal/ai-observability-judge-inputs.md @@ -54,6 +54,8 @@ Models without native structured-output support retain the JSON fallback, which Oversized or compressed completion responses skip the evaluation as a rejected request without disabling the connection. The evaluation records the response limit and how to configure the endpoint. These connection and response limits also apply when using the same provider in the playground. +Disconnecting from the playground releases the server's stream slot without waiting for an in-flight provider read. +The worker closes the connection when that read finishes or reaches the provider's deadline. ## System One judges diff --git a/ee/hogai/utils/asgi.py b/ee/hogai/utils/asgi.py index 98e7b62ed62d..988d0d6c3a05 100644 --- a/ee/hogai/utils/asgi.py +++ b/ee/hogai/utils/asgi.py @@ -12,6 +12,7 @@ def __init__(self, iterable: Iterable[T]) -> None: self._iterable: Iterable[T] = iterable self.sync_iterator: Iterator[T] | None = None self._lock = threading.Lock() + self._close_requested = threading.Event() self._closed = False def __aiter__(self) -> AsyncIterator[T]: @@ -21,15 +22,18 @@ async def __anext__(self) -> T: return await sync_to_async(self._next, thread_sensitive=False)() def _next(self) -> T: - with self._lock: - if self._closed: - raise StopAsyncIteration - if self.sync_iterator is None: - self.sync_iterator = iter(self._iterable) - return self.next(self.sync_iterator) + try: + with self._lock: + if self._close_requested.is_set(): + raise StopAsyncIteration + if self.sync_iterator is None: + self.sync_iterator = iter(self._iterable) + return self.next(self.sync_iterator) + finally: + if self._close_requested.is_set(): + self._close() def _close(self) -> None: - # Cancellation stops the await but not the worker, so closing must wait for an in-flight next(). with self._lock: if self._closed: return @@ -40,7 +44,11 @@ def _close(self) -> None: close() async def aclose(self) -> None: - await sync_to_async(self._close, thread_sensitive=False)() + self._close_requested.set() + # Cancellation cannot interrupt a sync read, so its worker closes the iterator when the read finishes. + if self._lock.acquire(blocking=False): + self._lock.release() + await sync_to_async(self._close, thread_sensitive=False)() @staticmethod def next(it: Iterator[T]) -> T: diff --git a/ee/hogai/utils/test/test_asgi.py b/ee/hogai/utils/test/test_asgi.py index 09dd1e819e05..dccb4ba01b7a 100644 --- a/ee/hogai/utils/test/test_asgi.py +++ b/ee/hogai/utils/test/test_asgi.py @@ -15,6 +15,7 @@ async def test_closes_cancelled_stream_in_worker(self, _name: str, reading: bool loop = asyncio.get_running_loop() read_started = asyncio.Event() close_started = asyncio.Event() + closed = asyncio.Event() release_read = threading.Event() release_close = threading.Event() closed_on: list[int] = [] @@ -29,6 +30,7 @@ def generate() -> Iterator[int]: loop.call_soon_threadsafe(close_started.set) assert release_close.wait(5) closed_on.append(threading.get_ident()) + loop.call_soon_threadsafe(closed.set) stream = SyncIterableToAsync(generate()) assert await anext(stream) == 1 @@ -41,11 +43,16 @@ def generate() -> Iterator[int]: cleanup = asyncio.create_task(stream.aclose()) try: - release_read.set() + if reading: + await asyncio.wait_for(cleanup, 5) + assert not close_started.is_set() + release_read.set() await asyncio.wait_for(close_started.wait(), 5) finally: + release_read.set() release_close.set() await asyncio.wait_for(cleanup, 5) + await asyncio.wait_for(closed.wait(), 5) assert len(closed_on) == 1 assert closed_on[0] != threading.get_ident() diff --git a/posthog/api/test/test_streaming.py b/posthog/api/test/test_streaming.py index c43d62466702..66ab438108d2 100644 --- a/posthog/api/test/test_streaming.py +++ b/posthog/api/test/test_streaming.py @@ -1,5 +1,6 @@ import gc import asyncio +import threading from collections.abc import AsyncGenerator, AsyncIterable, AsyncIterator, Iterator from http import HTTPStatus from typing import cast @@ -22,6 +23,8 @@ streaming_response, ) +from ee.hogai.utils.asgi import SyncIterableToAsync + def _gen() -> Iterator[bytes]: yield b"data: hello\n\n" @@ -184,36 +187,55 @@ def test_honors_content_type_and_does_not_inject_sse_headers(self): class TestSSEAsyncCancellation: - async def test_task_cancellation_counts_client_disconnect_not_error(self): - first_chunk_pulled = asyncio.Event() - - async def blocking(): + @pytest.mark.parametrize("synchronous", [False, True]) + async def test_task_cancellation_counts_client_disconnect_not_error(self, synchronous: bool) -> None: + loop = asyncio.get_running_loop() + read_started = asyncio.Event() + read_finished = asyncio.Event() + release_read = threading.Event() + + async def blocking() -> AsyncIterator[bytes]: yield b": ping\n\n" + read_started.set() await asyncio.Event().wait() # park forever; cancellation lands here + def blocking_sync() -> Iterator[bytes]: + try: + yield b": ping\n\n" + loop.call_soon_threadsafe(read_started.set) + assert release_read.wait(10) + yield b": ping\n\n" + finally: + loop.call_soon_threadsafe(read_finished.set) + # ASGI cancellation is a path where response.close() never runs, so the # generator's finally is the only thing releasing the cap slot; pin it # (baseline-relative: this test runs outside the slot-isolation fixture). baseline = streaming._active_stream_count - stream = _instrument_stream(blocking(), "test_async_cancel", _reserve_slot()) + endpoint = f"test_async_cancel_{synchronous}" + source = SyncIterableToAsync(blocking_sync()) if synchronous else blocking() + stream = _instrument_stream(source, endpoint, _reserve_slot()) assert isinstance(stream, AsyncIterable) - async def consume(): + async def consume() -> None: async for _ in stream: - first_chunk_pulled.set() + pass task = asyncio.ensure_future(consume()) - await first_chunk_pulled.wait() - assert _open_connections("test_async_cancel") == 1.0 - task.cancel() try: - await task - except asyncio.CancelledError: - pass - assert _open_connections("test_async_cancel") == 0.0 - assert _closed_total("test_async_cancel", "client_disconnect") == 1.0 - assert _closed_total("test_async_cancel", "error") == 0.0 - assert streaming._active_stream_count == baseline + await asyncio.wait_for(read_started.wait(), 5) + assert _open_connections(endpoint) == 1.0 + task.cancel() + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(task, 5) + assert _open_connections(endpoint) == 0.0 + assert _closed_total(endpoint, "client_disconnect") == 1.0 + assert _closed_total(endpoint, "error") == 0.0 + assert streaming._active_stream_count == baseline + finally: + release_read.set() + if synchronous: + await asyncio.wait_for(read_finished.wait(), 5) class TestSSEConcurrencyCap: From 2f99c866cdcbd3cbf1c289416f3c935162b9647d Mon Sep 17 00:00:00 2001 From: Jovan Sakovic <49978945+sakce@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:58:59 +0200 Subject: [PATCH 43/48] chore(data-warehouse): point data quality e2e at the models overview The Data Ops scene no longer has a data quality tab, so /data-ops?tab=data-quality falls back to the default tab and the spec timed out waiting for the check controls. The same overview still renders on the Models scene, so the test navigates there instead and only needs the data quality checks flag. Co-Authored-By: Claude Opus 5 Generated-By: PostHog Desktop Task-Id: 789f88f8-021d-4728-8b98-92c94b145315 --- playwright/e2e/data-quality-overview.spec.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/playwright/e2e/data-quality-overview.spec.ts b/playwright/e2e/data-quality-overview.spec.ts index ca0fe7b274dc..335bb35fb68e 100644 --- a/playwright/e2e/data-quality-overview.spec.ts +++ b/playwright/e2e/data-quality-overview.spec.ts @@ -1,5 +1,5 @@ /** - * Editing and deleting a data quality check from the Data Ops overview. + * Editing and deleting a data quality check from the Models overview. */ import { expect } from '@playwright/test' @@ -10,7 +10,7 @@ import { test } from '../utils/workspace-test-base' const CHECK_NAME = 'orders_has_rows' const SUBJECT_NAME = 'orders_e2e' -test('edits and deletes a check from Data Ops', async ({ page, playwrightSetup }) => { +test('edits and deletes a check from Models', async ({ page, playwrightSetup }) => { const workspace = await playwrightSetup.createWorkspace({ skip_onboarding: true, no_demo_data: true }) const auth = { headers: { @@ -41,11 +41,10 @@ test('edits and deletes a check from Data Ops', async ({ page, playwrightSetup } const check = await created.json() await mockFeatureFlags(page, { - [FEATURE_FLAGS.DATA_WAREHOUSE_SCENE]: true, [FEATURE_FLAGS.DATA_QUALITY_CHECKS]: true, }) await playwrightSetup.loginAndNavigateToTeam(page, workspace) - await page.goto('/data-ops?tab=data-quality') + await page.goto('/models?tab=data-quality') await page.getByLabel(`Expand checks for ${SUBJECT_NAME}`).click({ timeout: 30000 }) await expect(page.getByText(CHECK_NAME)).toBeVisible() From a177dfa9ba6ef16df68178ff11db3e56db4ea145 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:01:03 +0000 Subject: [PATCH 44/48] feat(tasks): show cited posthog objects as their real pages in artifacts A cited PostHog object in the task Artifacts tab now shows its own app page in a same-origin frame, for every object kind that has a page. The frame keeps the page's URL and navigation apart from the task page. A frame named posthog-embedded-page puts the app in a new embedded navigation mode, which renders the scene without the navigation, command palette or floating buttons. The per-kind insight, SQL, dashboard and replay embeds are gone, and a kind with no page keeps the card. The app CSP frame-ancestors now includes 'self', because the app could not frame its own pages before. Events captured in the frame carry embedded_page_frame: true. Also fixes the app failing to start when Django sends null bootstrap flags, which happens when it cannot evaluate flags locally and a last-seen flag cache exists. Generated-By: PostHog Desktop Task-Id: e2367959-ab88-40ff-8761-5632645167aa --- .../src/layout/navigation-3000/Navigation.tsx | 12 ++- .../navigation-3000/navigationLogic.tsx | 7 +- frontend/src/lib/utils/embeddedPageFrame.ts | 19 +++++ frontend/src/loadPostHogJS.tsx | 5 ++ frontend/src/scenes/AuthenticatedShell.tsx | 31 +++++-- posthog/csp_middleware.py | 6 +- posthog/test/test_csp_middleware.py | 2 +- .../TaskTracker/TaskRunArtifacts.stories.tsx | 28 ++----- .../components/ArtifactObjectEmbed.tsx | 84 +++++-------------- .../components/TaskRunArtifacts.tsx | 48 +++-------- .../scenes/TaskTracker/taskRunArtifacts.ts | 14 +++- 11 files changed, 117 insertions(+), 139 deletions(-) create mode 100644 frontend/src/lib/utils/embeddedPageFrame.ts diff --git a/frontend/src/layout/navigation-3000/Navigation.tsx b/frontend/src/layout/navigation-3000/Navigation.tsx index f4b27bd7d5e5..d0754c051b8a 100644 --- a/frontend/src/layout/navigation-3000/Navigation.tsx +++ b/frontend/src/layout/navigation-3000/Navigation.tsx @@ -134,7 +134,17 @@ export function Navigation({ } > {showMinimalNavigation && } -
{children}
+
+ {children} +
) } diff --git a/frontend/src/layout/navigation-3000/navigationLogic.tsx b/frontend/src/layout/navigation-3000/navigationLogic.tsx index 43ae74565e08..d1a5a3399409 100644 --- a/frontend/src/layout/navigation-3000/navigationLogic.tsx +++ b/frontend/src/layout/navigation-3000/navigationLogic.tsx @@ -5,6 +5,7 @@ import posthog from 'posthog-js' import { FEATURE_FLAGS } from 'lib/constants' import { featureFlagLogic } from 'lib/logic/featureFlagLogic' +import { isEmbeddedPageFrame } from 'lib/utils/embeddedPageFrame' import { onboardingVariantChrome, resolveOnboardingFlowVariant } from 'scenes/onboarding/onboardingVariants' import { organizationLogic } from 'scenes/organizationLogic' import { sceneLogic } from 'scenes/sceneLogic' @@ -13,7 +14,7 @@ import { Scene } from 'scenes/sceneTypes' import type { SceneConfig } from '../../scenes/sceneTypes' import { navigationLogic } from '../navigation/navigationLogic' -export type Navigation3000Mode = 'none' | 'minimal' | 'zen' | 'full' +export type Navigation3000Mode = 'none' | 'minimal' | 'zen' | 'embedded' | 'full' export type ZenModeTrigger = 'shortcut' | 'account_menu' | 'help_menu' | 'exit_button' | 'url' @@ -120,6 +121,10 @@ export const navigation3000Logic = kea([ activeSceneId: string | null, featureFlags: import('lib/logic/featureFlagLogic').FeatureFlagsSet ): Navigation3000Mode => { + // An embedded page frame sits inside another app page, so it shows the scene alone. + if (isEmbeddedPageFrame()) { + return 'embedded' + } if (zenMode) { return 'zen' } diff --git a/frontend/src/lib/utils/embeddedPageFrame.ts b/frontend/src/lib/utils/embeddedPageFrame.ts new file mode 100644 index 000000000000..0a1998e47526 --- /dev/null +++ b/frontend/src/lib/utils/embeddedPageFrame.ts @@ -0,0 +1,19 @@ +/** The name of a frame that shows an app page inside another app page, such as a cited object in a task. */ +export const EMBEDDED_PAGE_FRAME_NAME = 'posthog-embedded-page' + +/** + * True when this document is an app page inside an embedded page frame, so it renders without the app's + * navigation. The frame name stays when the page navigates inside the frame. The origin check makes sure + * only the app itself can turn the mode on. + */ +export function isEmbeddedPageFrame(): boolean { + if (typeof window === 'undefined' || window.name !== EMBEDDED_PAGE_FRAME_NAME || window.parent === window) { + return false + } + try { + return window.parent.location.origin === window.location.origin + } catch { + // A cross-origin parent throws on any read of its location. + return false + } +} diff --git a/frontend/src/loadPostHogJS.tsx b/frontend/src/loadPostHogJS.tsx index 07e5659e0ea9..887f6cfbf63a 100644 --- a/frontend/src/loadPostHogJS.tsx +++ b/frontend/src/loadPostHogJS.tsx @@ -3,6 +3,7 @@ import posthog, { BeforeSendFn, BrowserMetricsConfig, PostHogConfig, SessionReco import { FEATURE_FLAGS } from 'lib/constants' import { isOAuthMode } from 'lib/oauth/oauthClient' import { inStorybook, inStorybookTestRunner } from 'lib/utils/dom' +import { isEmbeddedPageFrame } from 'lib/utils/embeddedPageFrame' import { getAppContext } from 'lib/utils/getAppContext' import { startDetachedElementTracking } from './detachedElementTracker' @@ -121,6 +122,10 @@ export function loadPostHogJS(options: LoadPostHogJSOptions = {}): void { metrics: { network: true, serviceName: 'posthog-app', ...options.metrics }, before_send: options.beforeSend, loaded: (loadedInstance) => { + // pinned: analytics property. A page in a frame counts its own pageviews, so analysis can filter them. + if (isEmbeddedPageFrame()) { + loadedInstance.register({ embedded_page_frame: true }) + } if (loadedInstance.sessionRecording) { loadedInstance.sessionRecording._forceAllowLocalhostNetworkCapture = true } diff --git a/frontend/src/scenes/AuthenticatedShell.tsx b/frontend/src/scenes/AuthenticatedShell.tsx index bd96c75f29c5..e1fe39cea206 100644 --- a/frontend/src/scenes/AuthenticatedShell.tsx +++ b/frontend/src/scenes/AuthenticatedShell.tsx @@ -9,6 +9,7 @@ import { ToastCloseButton } from 'lib/lemon-ui/LemonToast/LemonToast' import { apiStatusLogic } from 'lib/logic/apiStatusLogic' import { eventIngestionRestrictionLogic } from 'lib/logic/eventIngestionRestrictionLogic' import { featureFlagLogic } from 'lib/logic/featureFlagLogic' +import { isEmbeddedPageFrame } from 'lib/utils/embeddedPageFrame' import { lazyWithRetry } from 'lib/utils/retryImport' import { WizardHandoffDialog } from 'scenes/onboarding/shared/wizard-sync/WizardHandoffDialog' import { WizardSyncDebugPanel } from 'scenes/onboarding/shared/wizard-sync/WizardSyncDebugPanel' @@ -43,6 +44,28 @@ export default function AuthenticatedShell({ children }: { children: React.React const { featureFlags } = useValues(featureFlagLogic) const { isDarkModeOn } = useValues(themeLogic) const runSyncEnabled = featureFlags[FEATURE_FLAGS.WIZARD_RUN_SYNC] === 'wizard-run' + const toasts = ( + } + position="bottom-right" + theme={isDarkModeOn ? 'dark' : 'light'} + /> + ) + + // The page around the frame already has the command palette, shortcuts and floating buttons. + if (isEmbeddedPageFrame()) { + return ( + <> +
+ {children} + +
+ {toasts} + + ) + } return ( <> @@ -71,13 +94,7 @@ export default function AuthenticatedShell({ children }: { children: React.React
)}
- } - position="bottom-right" - theme={isDarkModeOn ? 'dark' : 'light'} - /> + {toasts} ) } diff --git a/posthog/csp_middleware.py b/posthog/csp_middleware.py index ff5953c690a1..237c3f616f60 100644 --- a/posthog/csp_middleware.py +++ b/posthog/csp_middleware.py @@ -96,9 +96,11 @@ def app_frame_ancestor_sources() -> list[str]: """The origins that may frame the app, as `frame-ancestors` sources. A frame the app embeds has these origins in its ancestor chain too, so its own policy must - admit them. + admit them. `'self'` lets the app show one of its own pages in a frame, such as a PostHog + object cited in a task's Artifacts tab. A sandboxed document has an opaque origin, so it never + matches `'self'`. """ - sources = ["https://posthog.com", "https://preview.posthog.com"] + sources = ["'self'", "https://posthog.com", "https://preview.posthog.com"] if not (settings.DEBUG or settings.TEST) and settings.SITE_URL.endswith(".dev.posthog.dev"): # The posthog.com dev server frames the dev app. sources.append("http://localhost:8001") diff --git a/posthog/test/test_csp_middleware.py b/posthog/test/test_csp_middleware.py index e0b670af33a8..2a695214f69e 100644 --- a/posthog/test/test_csp_middleware.py +++ b/posthog/test/test_csp_middleware.py @@ -190,7 +190,7 @@ def test_signed_out_page_without_the_flag_enforces_only_frame_ancestors( # Framing is enforced ahead of the flag because it is what lets posthog.com frame the app. # The enforced list has to be the one the reported policy names, or the two drift apart. enforced = response["Content-Security-Policy"] - assert enforced.startswith("frame-ancestors https://posthog.com") + assert enforced.startswith("frame-ancestors 'self' https://posthog.com") assert "default-src" not in enforced assert enforced in reported diff --git a/products/posthog_ai/frontend/scenes/TaskTracker/TaskRunArtifacts.stories.tsx b/products/posthog_ai/frontend/scenes/TaskTracker/TaskRunArtifacts.stories.tsx index 8994a9be02a1..111f66fa3117 100644 --- a/products/posthog_ai/frontend/scenes/TaskTracker/TaskRunArtifacts.stories.tsx +++ b/products/posthog_ai/frontend/scenes/TaskTracker/TaskRunArtifacts.stories.tsx @@ -11,7 +11,6 @@ import { SceneLayout } from '~/layout/scenes/SceneLayout' import { TodayShell } from '~/layout/today/TodayShell' import { todayShellLogic } from '~/layout/today/todayShellLogic' import { mswDecorator } from '~/mocks/browser' -import TRENDS_LINE_INSIGHT from '~/mocks/fixtures/api/projects/team_id/insights/trendsLine.json' import type { MockSignature } from '~/mocks/utils' import type { @@ -203,7 +202,7 @@ const WALKTHROUGH_WEBM_BASE64 = function objectReference( id: string, name: string, - objectKind: 'insight' | 'dashboard' | 'flag' | 'experiment' | 'cohort' | 'survey', + objectKind: string, objectId: string, uploadedAt: string ): TaskRunArtifactResponseApi { @@ -231,21 +230,10 @@ const OBJECT_REFERENCES = [ objectReference('phref_experiment', 'Plan picker layout test', 'experiment', '12', '2026-09-28T18:09:00Z'), objectReference('phref_cohort', 'Trial starters on laptops', 'cohort', '3', '2026-09-28T18:08:00Z'), objectReference('phref_survey', 'Plan picker feedback', 'survey', 'survey-plan-picker', '2026-09-28T18:07:00Z'), + // No kind called `note` has a page, so this reference shows the card. + objectReference('phref_note', 'Pricing notes', 'note', 'pricing-notes', '2026-09-28T18:06:00Z'), ] -const CITED_INSIGHT = { ...TRENDS_LINE_INSIGHT, short_id: 'aBcD1234', name: 'Trial funnel by step' } - -// The live insight embed loads the saved insight, then runs its query. -const OBJECT_MOCKS = { - get: { - '/api/environments/:team_id/insights/': { count: 1, results: [CITED_INSIGHT] }, - '/api/projects/:team_id/insights/': { count: 1, results: [CITED_INSIGHT] }, - }, - post: { - '/api/environments/:team_id/query/': { results: CITED_INSIGHT.result }, - }, -} - const VIDEO_ARTIFACT: TaskRunArtifactResponseApi = { id: 'artifact-walkthrough', name: 'plan-picker-walkthrough.webm', @@ -534,8 +522,7 @@ export const Video: Story = { } function objectMocks(): ReturnType { - const mocks = taskMocks([...ARTIFACTS, ...OBJECT_REFERENCES]) - return { get: { ...mocks.get, ...OBJECT_MOCKS.get }, post: { ...mocks.post, ...OBJECT_MOCKS.post } } + return taskMocks([...ARTIFACTS, ...OBJECT_REFERENCES]) } export const PostHogObjects: Story = { @@ -543,9 +530,9 @@ export const PostHogObjects: Story = { render: () => , } -export const PostHogObjectWithoutEmbed: Story = { +export const PostHogObjectWithoutPage: Story = { parameters: { msw: { mocks: objectMocks() } }, - render: () => , + render: () => , } export const Versions: Story = { @@ -642,14 +629,13 @@ function livingMocks(): ReturnType { return { get: { ...mocks.get, - ...OBJECT_MOCKS.get, [`/api/projects/:team_id/tasks/${TASK_ID}/runs/:run_id/living_artifacts/`]: { artifacts: LIVING_DOCUMENTS, }, [`/api/projects/:team_id/tasks/${TASK_ID}/runs/:run_id/living_artifacts/doc-trial-chart/versions/:version/`]: () => new HttpResponse(CHART_SVG, { headers: { 'Content-Type': 'image/svg+xml' } }), }, - post: { ...mocks.post, ...OBJECT_MOCKS.post }, + post: mocks.post, } } diff --git a/products/posthog_ai/frontend/scenes/TaskTracker/components/ArtifactObjectEmbed.tsx b/products/posthog_ai/frontend/scenes/TaskTracker/components/ArtifactObjectEmbed.tsx index 32ad021d927b..614a3abd95d2 100644 --- a/products/posthog_ai/frontend/scenes/TaskTracker/components/ArtifactObjectEmbed.tsx +++ b/products/posthog_ai/frontend/scenes/TaskTracker/components/ArtifactObjectEmbed.tsx @@ -1,73 +1,29 @@ -import { Dashboard } from 'scenes/dashboard/Dashboard' -import { SessionRecordingPlayer } from 'scenes/session-recordings/player/SessionRecordingPlayer' -import { SessionRecordingPlayerMode } from 'scenes/session-recordings/player/sessionRecordingPlayerLogic' +import { useState } from 'react' -import { Query } from '~/queries/Query/Query' -import { NodeKind } from '~/queries/schema/schema-general' -import { DashboardPlacement, InsightShortId } from '~/types' +import { Spinner, cn } from '@posthog/quill-primitives' -import type { PostHogObjectRef } from '../taskRunArtifacts' - -function EmbedBody({ objectKind, objectId }: PostHogObjectRef): JSX.Element | null { - if (objectKind === 'insight') { - return ( -
-
- -
-
- ) - } - if (objectKind === 'hogql') { - // The SQL itself is the object id for this kind. - return ( -
- -
- ) - } - if (objectKind === 'dashboard') { - return ( -
- -
- ) - } - if (objectKind === 'replay') { - return ( -
- -
- ) - } - return null -} +import { EMBEDDED_PAGE_FRAME_NAME } from 'lib/utils/embeddedPageFrame' /** - * The live object a reference points at, rendered with the same components its own page uses. - * Kept in its own module so the insight, dashboard and replay code loads only when one opens. + * A cited object's own page, the same page its URL opens. The frame keeps that page's URL and navigation + * apart from the task page, and the frame name makes the app show the page without its navigation. */ -export function ArtifactObjectEmbed(ref: PostHogObjectRef): JSX.Element { - // These are LemonUI page components inside the quill artifacts pane, so they need PostHog's own color tokens back. +export function ArtifactObjectEmbed({ url, title }: { url: string; title: string }): JSX.Element { + const [loaded, setLoaded] = useState(false) return ( -
- +
+ {!loaded && ( +
+ +
+ )} +