Files
zeekay 40d120dbf7 refactor(api): say /v1/ in the Python callers
Every first-party Python caller and test now names the mount the API actually
answers on. Mechanical: URL literals only, and only where the /api/ was ours --
a preceding word character means it was a module path like insights/api/ or a
third-party host like slack.com/api/, and those are untouched.

Left saying /api/, each for a reason rather than for safety:
- products/warehouse_sources/.../sources/** is a catalogue of vendor endpoints
- services/llm-gateway deploys separately and is under a code freeze, so moving
  its two calls would break it during the skew
- the mcp_store and Slack/Teams redirect_uris are registered with third parties
  and must byte-match what was registered
- the Wizard and Raycast client-metadata paths are OAuth client_ids
- /api/surveys and /api/feature_flag/local_evaluation are SDK aliases the
  ingress routes to the Rust fleets, held by released SDK versions

Test URLconfs moved with the requests that call them, and the Datastore
user-selection fixtures now carry the leading slash that request.path has.
2026-08-08 18:38:44 -07:00

377 lines
14 KiB
TypeScript

import { Meta } from '@storybook/react'
import { router } from 'kea-router'
import { useEffect } from 'react'
import { dayjs } from 'lib/dayjs'
import { sampleOne } from 'lib/utils/arrays'
import { dateStringToDayJs } from 'lib/utils/dateFilters'
import { inStorybookTestRunner, uuid } from 'lib/utils/dom'
import { App } from 'scenes/App'
import { urls } from 'scenes/urls'
import { mswDecorator } from '~/mocks/browser'
import { MockSignature } from '~/mocks/utils'
import { LogMessage, LogSeverityLevel } from '~/queries/schema/schema-general'
import { PropertyFilterType } from '~/types'
function createSeededRandom(seed: number): () => number {
// LCG constants from Numerical Recipes
let m = 0x80000000 // 2^31
let a = 1103515245
let c = 12345
let state = seed ? seed : Math.floor(Math.random() * (m - 1))
return function () {
state = (a * state + c) % m
return state / m
}
}
const deterministicRandom = createSeededRandom(1234)
const delayIfNotTestRunner = async (): Promise<void> => {
await new Promise((resolve) => setTimeout(resolve, inStorybookTestRunner() ? 0 : 200 + Math.random() * 1000))
}
const attributeExamples: Record<
PropertyFilterType.LogAttribute | PropertyFilterType.LogResourceAttribute,
Record<string, string[]>
> = {
[PropertyFilterType.LogResourceAttribute]: {
'service.name': [
'insights-web',
'insights-feature-flags',
'insights-surveys',
'insights-web-django',
'cdp-precalculated-filters-consumer',
'cdp-events-consumer',
'cdp-legacy-events-consumer',
'capture',
],
'k8s.namespace.name': ['insights', 'internal', 'billing'],
'k8s.pod.name': [
'insights-web',
'insights-feature-flags',
'insights-surveys',
'insights-web-django',
'cdp-precalculated-filters-consumer',
'cdp-events-consumer',
'cdp-legacy-events-consumer',
'capture',
],
'k8s.container.restart_count': ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'],
'k8s.node.name': [
'node-1',
'node-2',
'node-3',
'node-4',
'node-5',
'node-6',
'node-7',
'node-8',
'node-9',
'node-10',
],
},
[PropertyFilterType.LogAttribute]: {
'log.iostream': ['stdout', 'stderr'],
duration: ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10'],
method: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD'],
},
}
const EXAMPLES: Record<
string,
{
resource_attributes: Record<string, string>
logs: { message: string; level: LogSeverityLevel; attributes?: Record<string, string> }[]
}
> = {
'insights-web': {
resource_attributes: {
'k8s.namespace.name': 'insights',
'service.name': 'insights-web',
'k8s.pod.name': 'insights-web',
'k8s.container.name': 'insights-web',
},
logs: [
{
message:
'{"request_id": "0904e6ff-da7e-4d66-af79-0c111bb47cab", "ip": "1.0.0.1", "event": "geoIP computation error: The address 172.0.1.1 is not in the database.", "host": "us.i.hanzo.ai", "container_hostname": "insights-web-django-c5f54bd98-cswsg", "timestamp": "2025-10-10T12:56:52.826524Z", "logger": "insights.geoip", "level": "error", "pid": 65403, "tid": 281466655207264, "exception": "Traceback (most recent call last):\n File "/code/insights/geoip.py", line 52, in get_geoip_properties\n geoip_properties = geoip.city(ip_address)\n ^^^^^^^^^^^^^^^^^^^^^^\n File "/python-runtime/lib/python3.11/site-packages/django/contrib/gis/geoip2/base.py", line 181, in city\n return City(self._city.city(enc_query))\n ^^^^^^^^^^^^^^^^^^^^^^^^^^\n File "/python-runtime/lib/python3.11/site-packages/geoip2/database.py", line 150, in city\n return cast(City, self._model_for(geoip2.models.City, "City", ip_address))\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File "/python-runtime/lib/python3.11/site-packages/geoip2/database.py", line 253, in _model_for\n (record, prefix_len) = self._get(types, ip_address)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File "/python-runtime/lib/python3.11/site-packages/geoip2/database.py", line 240, in _get\n raise geoip2.errors.AddressNotFoundError(\ngeoip2.errors.AddressNotFoundError: The address 172.0.1.1 is not in the database."}',
level: 'error',
},
{
message: `{"request_id": "8cbf3d86-5270-4129-a9a7-5c0908a6a806", "ip": "1.0.0.1", "request": "<ASGIRequest: OPTIONS '/array/123/config'>", "user_agent": "Mozilla/5.0 (iPad; CPU OS 18_6_2 like Mac OS X) AppleWebKit/605.1.15", "event": "request_started", "host": "us-assets.i.hanzo.ai", "container_hostname": "insights-web-django-c5f54bd98-z2kjj", "timestamp": "2025-10-10T12:58:08.090793Z", "logger": "django_structlog.middlewares.request", "level": "info", "pid": 44582, "tid": 281465917009760}`,
level: 'info',
},
{
message:
'/python-runtime/lib/python3.11/site-packages/django/http/response.py:517: Warning: StreamingHttpResponse must consume synchronous iterators in order to serve them asynchronously. Use an asynchronous iterator instead.',
level: 'warn',
},
],
},
'cdp-events-consumer': {
resource_attributes: {
'k8s.namespace.name': 'insights',
'service.name': 'cdp-events-consumer',
'k8s.pod.name': 'cdp-events-consumer',
'k8s.container.name': 'cdp-events-consumer',
},
logs: [
{
message: '[CDP-PROCESSED-EVENTS] 🔁 CdpEventsConsumer - handling batch',
level: 'info',
attributes: {
size: '100',
},
},
{
message: '[CDP-PROCESSED-EVENTS] ◆ [InsightsFunction] Filter took longer than expected',
level: 'warn',
attributes: {
insights_function_id: '123',
insights_function_name: 'Filter fail test',
duration: '1000',
},
},
],
},
}
// Make a deterministic log of all messages. We basically want to create a tonne of logs over an example time period with some deterministic randomness to it.
const generateLogs = (): LogMessage[] => {
const results: LogMessage[] = []
const startTime = inStorybookTestRunner()
? dayjs().utc().subtract(15, 'minutes')
: dayjs().utc().subtract(1, 'hours')
const endTime = dayjs().utc()
// Iterate each minute adding N logs to the results
let currentTime = startTime
while (currentTime.isBefore(endTime)) {
Object.values(EXAMPLES).forEach((example) => {
const logsToAdd = Math.floor(deterministicRandom() * 3)
for (let i = 0; i < logsToAdd; i++) {
const log = sampleOne<(typeof example.logs)[0]>(example.logs)
results.push({
uuid: uuid(),
trace_id: uuid(),
span_id: uuid(),
resource_attributes: example.resource_attributes,
body: log.message,
attributes: { ...log.attributes },
timestamp: currentTime.toISOString(),
observed_timestamp: currentTime.toISOString(),
severity_text: log.level,
severity_number: 13,
level: log.level,
instrumentation_scope: 'any',
event_name: 'any',
})
}
})
currentTime = currentTime.add(1, 'minutes')
}
return results
}
let _cachedLogs: LogMessage[] | null = null
const getLogs = async (
body: any
): Promise<{
startTime: dayjs.Dayjs
endTime: dayjs.Dayjs
logs: LogMessage[]
}> => {
if (!_cachedLogs?.length) {
_cachedLogs = generateLogs()
}
const ALL_LOGS_GENERATED = _cachedLogs
const severityLevels = body.query?.severityLevels ?? []
const startDate = dateStringToDayJs(body.query?.dateRange?.date_from ?? null) ?? dayjs().subtract(30, 'minutes')
const endDate = dateStringToDayJs(body.query?.dateRange?.date_to ?? null) ?? dayjs()
const logs = ALL_LOGS_GENERATED.filter((log) => {
if (startDate && startDate.isAfter(dayjs(log.timestamp))) {
return false
}
if (endDate && endDate.isBefore(dayjs(log.timestamp))) {
return false
}
if (
body.query?.serviceNames?.length &&
!body.query?.serviceNames.includes(log.resource_attributes['service.name'])
) {
return false
}
if (severityLevels.length && !severityLevels.includes(log.severity_text.toLowerCase())) {
return false
}
return true
})
return {
startTime: startDate,
endTime: endDate,
logs,
}
}
const queryMock: MockSignature = async ({ request }) => {
await delayIfNotTestRunner()
const body = (await request.json()) as Record<string, any>
const { logs } = await getLogs(body)
const limit = body.query?.limit ?? 100
const offset = body.query?.offset ?? 0
const results = logs.slice(offset, offset + limit)
return [200, { results: results, maxExportableLogs: 5000 }]
}
const sparklineMock: MockSignature = async ({ request }) => {
await delayIfNotTestRunner()
const body = (await request.json()) as Record<string, any>
const { startTime, endTime, logs } = await getLogs(body)
// Interval selection
const hoursSpan = endTime.diff(startTime, 'hours', true)
let intervalMins = 1
if (hoursSpan >= 12 && hoursSpan < 24) {
intervalMins = 5
} else if (hoursSpan >= 24 * 7) {
intervalMins = 60
}
// Build buckets
type Counts = { info: number; warn: number; error: number; total: number }
const bucketMap = new Map<string, Counts>()
// Pre-seed buckets so we include empty intervals
let cursor = startTime.startOf('minute')
const endCursor = endTime.startOf('minute')
while (cursor.isBefore(endCursor)) {
const key = cursor.toISOString()
bucketMap.set(key, { info: 0, warn: 0, error: 0, total: 0 })
cursor = cursor.add(intervalMins, 'minute')
}
// Assign logs to buckets
for (const log of logs) {
const ts = dayjs(log.timestamp)
if (ts.isBefore(startTime) || !ts.isBefore(endTime)) {
continue
}
const minsFromStart = ts.diff(startTime, 'minute')
const bucketIndex = Math.floor(minsFromStart / intervalMins)
const bucketStart = startTime.startOf('minute').add(bucketIndex * intervalMins, 'minute')
const key = bucketStart.toISOString()
const level = String(log.severity_text ?? log.level ?? 'info').toLowerCase()
const counts = bucketMap.get(key)
if (!counts) {
continue
}
if (level === 'error') {
counts.error += 1
} else if (level === 'warn' || level === 'warning') {
counts.warn += 1
} else {
counts.info += 1
}
counts.total += 1
}
const results: { count: number; level: string; time: string }[] = []
// Emit ordered response
Array.from(bucketMap.entries())
.sort(([a], [b]) => (a < b ? -1 : 1))
.forEach(([timestamp, counts]) =>
Object.entries(counts).forEach(([level, count]) => {
results.push({
count: count,
level: level,
time: timestamp,
})
})
)
return [200, results]
}
const attributesMock: MockSignature = async ({ request }) => {
await delayIfNotTestRunner()
const type = (new URL(request.url).searchParams.get('attribute_type') ?? 'log_attribute') as
| PropertyFilterType.LogAttribute
| PropertyFilterType.LogResourceAttribute
const results = Object.keys(attributeExamples[type]).map((key) => ({
id: key,
name: key,
type: type,
}))
return [200, results]
}
const valuesMock: MockSignature = async ({ request }) => {
await delayIfNotTestRunner()
const url = new URL(request.url)
const key = url.searchParams.get('key') ?? ''
const type = (url.searchParams.get('attribute_type') ?? 'log_attribute') as
| PropertyFilterType.LogAttribute
| PropertyFilterType.LogResourceAttribute
const results = (attributeExamples[type][key] ?? []).map((value) => ({
id: value,
name: value,
}))
return [200, results]
}
export default {
title: 'Scenes-App/Logs',
decorators: [
// mocks used by all stories in this file
mswDecorator({
get: {
'/v1/environments/:team_id/logs/attributes': attributesMock,
'/v1/environments/:team_id/logs/values': valuesMock,
'/v1/environments/:team_id/logs/has_logs': () => [200, { hasLogs: true }],
},
post: {
'/v1/environments/:team_id/logs/query': queryMock,
'/v1/environments/:team_id/logs/sparkline': sparklineMock,
},
}),
],
parameters: {
layout: 'fullscreen',
options: { showPanel: false },
viewMode: 'story',
mockDate: '2023-02-18',
testOptions: {
waitForSelector: '[data-attr="logs-viewer"]',
},
}, // scene mode
tags: ['test-skip'],
} as Meta
export function LogsScene(): JSX.Element {
useEffect(() => {
router.actions.push(urls.logs())
}, [])
return <App />
}
LogsScene.parameters = {
featureFlags: [],
}