Files
zeekay 10e6fec4c0 debrand: the brand token is INSIGHTS_, and a wrong one stops the container
The rule table mapped HOG_ -> FN_ ahead of any POSTHOG rule, so POSTHOG_FOO
split into POST + HOG_ and came out POSTFN_FOO. Nothing sets that. The live
pods set INSIGHTS_SKIP_MIGRATION_CHECKS, which bin/migrate-check reads on the
container entrypoint, so the misspelling does not degrade a feature -- it runs
migrate --check against a database whose history this tree does not share and
the container never starts.

Both replacements are measured against the shipped tree rather than chosen:
every HOG_ name with a counterpart there is spelled INSIGHTS_ (HOG_QL ->
INSIGHTS_QL, HOG_FUNCTION_TEMPLATES -> INSIGHTS_FUNCTION_TEMPLATES, 12 of 12
determinate cases), and FN_ appears in it nowhere. Upstream carries 95 HOG_
tokens and zero FN_ ones, so repairing FN_ back out is exactly the inverse of
the bad rule and cannot collide with an upstream name.

The rule is fixed for the next convergence, and the tree it already produced is
repaired in the same commit -- the codemod is idempotent against its own output,
so it cannot undo this itself.
2026-08-01 17:42:28 -07:00

351 lines
15 KiB
TypeScript

import { expectLogic } from 'kea-test-utils'
import { insightsApi } from 'scenes/insights/utils/api'
import { NodeKind } from '~/queries/schema/schema-general'
import { initKeaTests } from '~/test/init'
import {
AccessControlLevel,
AccessControlResourceType,
AppContext,
FilterLogicalOperator,
PropertyFilterType,
PropertyOperator,
UniversalFiltersGroup,
UniversalFiltersGroupValue,
} from '~/types'
import {
metricsAttributesRetrieve,
metricsQueryCreate,
metricsValuesRetrieve,
} from 'products/metrics/frontend/generated/api'
import { metricNamePickerLogic } from './metricNamePickerLogic'
import { metricsViewerLogic, NEW_QUERY_STARTED_ERROR_MESSAGE } from './metricsViewerLogic'
jest.mock('products/metrics/frontend/generated/api', () => ({
...jest.requireActual('products/metrics/frontend/generated/api'),
metricsValuesRetrieve: jest.fn(),
metricsAttributesRetrieve: jest.fn(),
metricsQueryCreate: jest.fn(),
}))
jest.mock('scenes/insights/utils/api', () => ({
...jest.requireActual('scenes/insights/utils/api'),
insightsApi: { create: jest.fn() },
}))
const filterGroupWith = (filters: Record<string, any>[]): UniversalFiltersGroup => ({
type: FilterLogicalOperator.And,
values: [
{
type: FilterLogicalOperator.And,
values: filters.map(
(filter) => ({ type: PropertyFilterType.MetricAttribute, ...filter }) as UniversalFiltersGroupValue
),
},
],
})
const PICKER_ITEMS = [
{ name: 'requests_total', metric_type: 'sum' },
{ name: 'queue_depth', metric_type: 'gauge' },
{ name: 'request_duration', metric_type: 'histogram' },
{ name: 'mystery_metric', metric_type: 'unknown_type' },
]
const setResourceAccess = (overrides: Partial<Record<AccessControlResourceType, AccessControlLevel>>): void => {
window.INSIGHTS_APP_CONTEXT = {
...window.INSIGHTS_APP_CONTEXT,
resource_access_control: {
...window.INSIGHTS_APP_CONTEXT?.resource_access_control,
[AccessControlResourceType.Metrics]: AccessControlLevel.Viewer,
[AccessControlResourceType.Insight]: AccessControlLevel.Editor,
[AccessControlResourceType.Tracing]: AccessControlLevel.Viewer,
...overrides,
},
} as AppContext
}
describe('metricsViewerLogic', () => {
let logic: ReturnType<typeof metricsViewerLogic.build>
beforeEach(() => {
setResourceAccess({})
initKeaTests()
jest.mocked(metricsValuesRetrieve).mockResolvedValue({ results: PICKER_ITEMS })
jest.mocked(metricsQueryCreate).mockReset().mockResolvedValue({ results: [] })
jest.mocked(metricsAttributesRetrieve).mockReset()
jest.mocked(insightsApi.create).mockReset()
logic = metricsViewerLogic()
logic.mount()
metricNamePickerLogic.actions.loadItemsSuccess(PICKER_ITEMS)
})
afterEach(() => {
logic?.unmount()
})
// Regression: the viewer defaulted every metric to `sum`, so selecting a
// cumulative counter summed raw monotonic readings across pods and buckets —
// a huge meaningless total instead of the actual increase.
it.each([
['requests_total', 'increase'],
['queue_depth', 'avg'],
['request_duration', 'p95'],
])('selecting %s applies the type-appropriate aggregation %s', (metricName, expected) => {
logic.actions.setMetricName(metricName)
expect(logic.values.aggregation).toBe(expected)
})
it('keeps a manual aggregation pick until the metric changes', () => {
logic.actions.setMetricName('requests_total')
logic.actions.setAggregation('rate')
expect(logic.values.aggregation).toBe('rate')
logic.actions.setMetricName('queue_depth')
expect(logic.values.aggregation).toBe('avg')
})
it('leaves aggregation untouched for unknown metric types', () => {
logic.actions.setMetricName('requests_total')
logic.actions.setMetricName('mystery_metric')
expect(logic.values.aggregation).toBe('increase')
})
// metricsQueryNode is what "Save as insight" persists: a wrong mapping here
// silently saves insights that re-run a different query than the viewer showed.
it('maps viewer state to a MetricsQuery node, translating p95 to quantile', () => {
logic.actions.setMetricName('request_duration')
logic.actions.setGroupByKeys(['container'])
logic.actions.setFilterGroup(
filterGroupWith([{ key: 'namespace', operator: PropertyOperator.Exact, value: ['insights'] }])
)
logic.actions.setDateFrom('-24h')
expect(logic.values.aggregation).toBe('p95')
expect(logic.values.metricsQueryNode).toEqual({
kind: NodeKind.MetricsQuery,
clauses: [
{
name: 'a',
metricName: 'request_duration',
aggregation: 'quantile',
metricType: 'histogram',
quantile: 0.95,
filters: [{ key: 'namespace', op: 'eq', value: 'insights' }],
groupBy: [{ key: 'container' }],
},
],
dateRange: { date_from: '-24h' },
})
})
it('produces no MetricsQuery node without a metric name', () => {
expect(logic.values.metricsQueryNode).toBeNull()
})
// A type outside the API enum (or a metric missing from the picker list) must be
// omitted, not persisted — the backend rejects unknown metric types.
it('omits metricType from the node when the picked type is unknown', () => {
logic.actions.setMetricName('mystery_metric')
expect(logic.values.metricsQueryNode?.clauses[0]).not.toHaveProperty('metricType')
})
// The picker's items are live search results: typing a new search after picking
// a metric replaces them. The picked type must be latched, not derived, or a
// save at that moment silently persists an untyped (blendable) query.
it('keeps the picked metric type when the picker search results change', () => {
logic.actions.setMetricName('queue_depth')
metricNamePickerLogic.actions.loadItemsSuccess([{ name: 'http_requests', metric_type: 'sum' }] as any)
expect(logic.values.metricsQueryNode?.clauses[0].metricType).toBe('gauge')
})
it('backfills the metric type when the picker loads after the metric was set', () => {
metricNamePickerLogic.actions.loadItemsSuccess([])
logic.actions.setMetricName('queue_depth')
expect(logic.values.metricsQueryNode?.clauses[0]).not.toHaveProperty('metricType')
metricNamePickerLogic.actions.loadItemsSuccess(PICKER_ITEMS)
expect(logic.values.metricsQueryNode?.clauses[0].metricType).toBe('gauge')
})
// "Add to dashboard" must not create a fresh insight on every click — repeated
// clicks for an unchanged query would litter saved insights with duplicates.
// The mock normalizes the echoed query (like the API can: injected defaults,
// version stamps), so reuse must not depend on the server round-tripping the
// node byte-for-byte.
it('add to dashboard saves the insight once, then reuses it while the query is unchanged', async () => {
jest.mocked(insightsApi.create).mockImplementation(
async (insight: any) =>
({ id: 1, short_id: 'abc123', ...insight, query: { ...insight.query, version: 1 } }) as any
)
logic.actions.setMetricName('queue_depth')
logic.actions.addToDashboard()
await expectLogic(logic).toDispatchActions(['saveAsInsightSuccess', 'openAddToDashboardModal'])
expect(logic.values.isAddToDashboardModalOpen).toBe(true)
expect(insightsApi.create).toHaveBeenCalledTimes(1)
logic.actions.closeAddToDashboardModal()
logic.actions.addToDashboard()
await expectLogic(logic).toDispatchActions(['openAddToDashboardModal'])
expect(logic.values.isAddToDashboardModalOpen).toBe(true)
expect(insightsApi.create).toHaveBeenCalledTimes(1)
})
it('add to dashboard saves a fresh insight after the query changes', async () => {
jest.mocked(insightsApi.create).mockImplementation(
async (insight: any) => ({ id: 1, short_id: 'abc123', ...insight }) as any
)
logic.actions.setMetricName('queue_depth')
logic.actions.addToDashboard()
await expectLogic(logic).toDispatchActions(['openAddToDashboardModal'])
logic.actions.closeAddToDashboardModal()
logic.actions.setAggregation('rate')
logic.actions.addToDashboard()
await expectLogic(logic).toDispatchActions(['saveAsInsightSuccess', 'openAddToDashboardModal'])
expect(insightsApi.create).toHaveBeenCalledTimes(2)
})
// A failed add-to-dashboard save must not leave the flow armed: a later plain
// "Save as insight" success would unexpectedly pop the modal.
it('a later plain save does not open the modal after a failed add-to-dashboard save', async () => {
jest.mocked(insightsApi.create).mockRejectedValueOnce(new Error('boom'))
logic.actions.setMetricName('queue_depth')
logic.actions.addToDashboard()
await expectLogic(logic).toDispatchActions(['saveAsInsightFailure'])
jest.mocked(insightsApi.create).mockImplementation(
async (insight: any) => ({ id: 1, short_id: 'abc123', ...insight }) as any
)
logic.actions.saveAsInsight()
await expectLogic(logic).toDispatchActions(['saveAsInsightSuccess'])
expect(logic.values.isAddToDashboardModalOpen).toBe(false)
})
// A failed query (bad regex, 500) used to render the same "No data" empty state as a genuinely
// empty result. The failure records the message so the viewer can show a real error instead.
// kea-loaders dispatches `<key>Failure(error.message, error)`, so the reducer reads the message.
it('records a real query failure in queryError', () => {
logic.actions.fetchQueryResultsFailure('Invalid regex pattern', new Error('Invalid regex pattern'))
expect(logic.values.queryError).toBe('Invalid regex pattern')
})
// The debounced viewer aborts the in-flight query on every change; that cancellation rejects with
// NEW_QUERY_STARTED_ERROR_MESSAGE (whose text has no "abort"), and must not become an error banner.
it('does not record an aborted (superseded) query as an error', () => {
logic.actions.fetchQueryResultsFailure(
NEW_QUERY_STARTED_ERROR_MESSAGE,
new DOMException(NEW_QUERY_STARTED_ERROR_MESSAGE, 'AbortError')
)
expect(logic.values.queryError).toBeNull()
})
// The filter bar's property filters must translate into the backend's Prometheus-style
// matchers: operator mapping, multi-value alternation (with regex escaping), and skipping
// chips that are still being edited. A bad mapping silently filters the chart wrong.
it.each([
[
'exact -> eq',
{ key: 'env', operator: PropertyOperator.Exact, value: ['prod'] },
{ key: 'env', op: 'eq', value: 'prod' },
],
[
'is_not -> neq',
{ key: 'env', operator: PropertyOperator.IsNot, value: ['prod'] },
{ key: 'env', op: 'neq', value: 'prod' },
],
[
'regex -> regex',
{ key: 'svc', operator: PropertyOperator.Regex, value: ['checkout.*'] },
{ key: 'svc', op: 'regex', value: 'checkout.*' },
],
[
'not_regex -> not_regex',
{ key: 'path', operator: PropertyOperator.NotRegex, value: ['/health'] },
{ key: 'path', op: 'not_regex', value: '/health' },
],
[
'multi-value exact -> anchored, escaped regex',
{ key: 'pod', operator: PropertyOperator.Exact, value: ['api.1', 'api.2'] },
{ key: 'pod', op: 'regex', value: '^(?:api\\.1|api\\.2)$' },
],
[
'multi-value is_not -> anchored not_regex',
{ key: 'pod', operator: PropertyOperator.IsNot, value: ['a', 'b'] },
{ key: 'pod', op: 'not_regex', value: '^(?:a|b)$' },
],
])('maps filter bar chip (%s) to a backend matcher', (_name, propertyFilter, expected) => {
logic.actions.setFilterGroup(filterGroupWith([propertyFilter]))
expect(logic.values.queryFilters).toEqual([expected])
})
it('skips chips that are still being edited or use unsupported operators', () => {
logic.actions.setFilterGroup(
filterGroupWith([
{ key: 'env', operator: PropertyOperator.Exact, value: [] }, // value not picked yet
{ key: '', operator: PropertyOperator.Exact, value: ['x'] }, // no key
{ key: 'env', operator: PropertyOperator.IContains, value: ['pr'] }, // unsupported operator
{ key: 'env', operator: PropertyOperator.Exact, value: ['prod'] },
])
)
expect(logic.values.queryFilters).toEqual([{ key: 'env', op: 'eq', value: 'prod' }])
})
// The group-by picker shipped with `options={[]}` and never fetched, so it offered no
// attribute keys. Typing must query the attributes endpoint (scoped by search) and map
// `{ name }` rows into `{ key, label }` options.
it('group-by search fetches attribute keys and maps them into options', async () => {
jest.mocked(metricsAttributesRetrieve).mockResolvedValue({
results: [{ name: 'env' }, { name: 'service_name' }],
count: 2,
})
await expectLogic(logic, () => {
logic.actions.setGroupBySearch('e')
}).toDispatchActions(['loadAttributeKeyOptions', 'loadAttributeKeyOptionsSuccess'])
expect(metricsAttributesRetrieve).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ search: 'e' })
)
expect(logic.values.attributeKeyOptions).toEqual([
{ key: 'env', label: 'env' },
{ key: 'service_name', label: 'service_name' },
])
})
it('does not call metrics APIs without metrics viewer access', async () => {
setResourceAccess({ [AccessControlResourceType.Metrics]: AccessControlLevel.None })
jest.mocked(metricsValuesRetrieve).mockClear()
await expectLogic(metricNamePickerLogic, () => {
metricNamePickerLogic.actions.loadItems({})
}).toDispatchActions(['loadItemsSuccess'])
logic.actions.setMetricName('queue_depth')
await expectLogic(logic, () => {
logic.actions.fetchQueryResults({})
}).toDispatchActions(['fetchQueryResultsSuccess'])
await expectLogic(logic, () => {
logic.actions.setGroupBySearch('env')
}).toDispatchActions(['loadAttributeKeyOptionsSuccess'])
expect(metricsValuesRetrieve).not.toHaveBeenCalled()
expect(metricsQueryCreate).not.toHaveBeenCalled()
expect(metricsAttributesRetrieve).not.toHaveBeenCalled()
})
it('does not create insights without insight editor access', async () => {
setResourceAccess({ [AccessControlResourceType.Insight]: AccessControlLevel.Viewer })
logic.actions.setMetricName('queue_depth')
await expectLogic(logic, () => {
logic.actions.saveAsInsight()
}).toDispatchActions(['saveAsInsightSuccess'])
logic.actions.addToDashboard()
expect(insightsApi.create).not.toHaveBeenCalled()
expect(logic.values.pendingAddToDashboard).toBe(false)
})
})