mirror of
https://github.com/hanzoai/insights.git
synced 2026-10-30 16:15:31 +00:00
The rename runs on the way in and nothing checked it afterwards, so paths and content drifted apart in three places, and each one broke something quietly. The script language had three names at once. Files on disk carried `.iql`/`.iqle`, the source matchers said `.script`, and the bytecode matchers said `.hoge`, so the test harness globbed zero files and the STL compiler regenerated an empty table. It is `script` now, in one place: `.script` source, `.scriptc` bytecode, `bin/script` to run one and `bin/scriptc` to compile one. 190 TypeScript specifiers named files that had been renamed away, which is the whole CDP consumer graph -- the event worker, not the web boot. `--check` only ever read Python imports; it reads both module systems now, and repoints both. `-hog-` became `-mascot-` in paths while content said `script`, so the code review product's skill directories stopped matching the prefix that discovers them and it ran with no perspectives at all. PATH_REGEX is CONTENT_REGEX -- the same list, not a copy -- so a rule can no longer land in one and miss the other, and the four disagreeing `hog` path rules collapse into the one word rule content already had. `HOGLI_*` becomes `INSIGHTSCLI_*` at every reader and writer, with no alias: an env var read under two names is two names. The seven casings of `hogql` and five of `hogvm` collapse into one rule per prefix casing, which leaves the suffix alone because `QL` is SQL's and `VM` is a virtual machine's. `--check` now refuses every retired spelling, in content and in paths, and draws the line the rules draw: `insights_hogfunction` is a Postgres table so it is left alone, and a migration filename is its identity in `django_migrations` so it is frozen rather than failed. Third-party names we cannot rename are reported as pending, never exempted.
336 lines
14 KiB
TypeScript
336 lines
14 KiB
TypeScript
import { MakeLogicType, actions, events, kea, listeners, path, reducers, selectors } from 'kea'
|
|
|
|
import api from 'lib/api'
|
|
import { chunk } from 'lib/utils/arrays'
|
|
|
|
import { InsightsQLQuery, NodeKind } from '~/queries/schema/schema-general'
|
|
import { escapeInsightsQLString } from '~/queries/utils'
|
|
|
|
import { parsePartialJSON } from './utils'
|
|
|
|
export interface TraceMessages {
|
|
lastInput: unknown
|
|
lastOutput: unknown
|
|
// Latest $ai_generation payloads, used when the preferred $ai_trace
|
|
// state wrapper is missing or resolves to an empty messages array.
|
|
lastInputFallback: unknown
|
|
lastOutputFallback: unknown
|
|
}
|
|
|
|
export interface TraceLazyLoadRequest {
|
|
id: string
|
|
createdAt: string | null
|
|
}
|
|
|
|
const BATCH_MAX_SIZE = 100
|
|
const BATCH_DEBOUNCE_MS = 0
|
|
// Cap per field in characters. Bounds network payload and Datastore
|
|
// aggregation state; truncated JSON is recovered with parsePartialJSON.
|
|
const FIELD_TRUNCATE_CHARS = 2000
|
|
const TRACE_ID_MAX_LENGTH = 128
|
|
// Matches TracesQueryDateRange.CAPTURE_RANGE_MINUTES on the backend runner.
|
|
const BATCH_WINDOW_BUFFER_MINUTES = 10
|
|
|
|
// "2026-04-11T19:20:55.828Z" → "2026-04-11 19:20:55" (Datastore toDateTime format, UTC).
|
|
function formatInsightsqlDateTime(d: Date): string {
|
|
return d.toISOString().replace('T', ' ').slice(0, 19)
|
|
}
|
|
|
|
// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
|
|
export interface traceMessagesLazyLoaderLogicValues {
|
|
getTraceMessages: (traceId: string) => TraceMessages | null | undefined
|
|
loadingTraceIds: Set<string>
|
|
messagesByTraceId: Record<string, TraceMessages | null>
|
|
}
|
|
|
|
// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
|
|
export interface traceMessagesLazyLoaderLogicActions {
|
|
ensureTraceMessagesLoaded: (requests: TraceLazyLoadRequest[]) => {
|
|
requests: TraceLazyLoadRequest[]
|
|
}
|
|
loadTraceMessagesBatchFailure: (requestedTraceIds: string[]) => {
|
|
requestedTraceIds: string[]
|
|
}
|
|
loadTraceMessagesBatchSuccess: (
|
|
results: Record<string, TraceMessages>,
|
|
requestedTraceIds: string[]
|
|
) => {
|
|
requestedTraceIds: string[]
|
|
results: Record<string, TraceMessages>
|
|
}
|
|
markTraceIdsLoading: (traceIds: string[]) => {
|
|
traceIds: string[]
|
|
}
|
|
}
|
|
|
|
// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
|
|
export interface traceMessagesLazyLoaderLogicMeta {
|
|
__keaTypeGenInternalSelectorTypes: {
|
|
getTraceMessages: (
|
|
messagesByTraceId: Record<string, TraceMessages | null>
|
|
) => (traceId: string) => TraceMessages | null | undefined
|
|
}
|
|
}
|
|
|
|
export type traceMessagesLazyLoaderLogicType = MakeLogicType<
|
|
traceMessagesLazyLoaderLogicValues,
|
|
traceMessagesLazyLoaderLogicActions,
|
|
Record<string, any>,
|
|
traceMessagesLazyLoaderLogicMeta
|
|
>
|
|
|
|
export const traceMessagesLazyLoaderLogic = kea<traceMessagesLazyLoaderLogicType>([
|
|
path(['products', 'ai_observability', 'frontend', 'traceMessagesLazyLoaderLogic']),
|
|
|
|
actions({
|
|
ensureTraceMessagesLoaded: (requests: TraceLazyLoadRequest[]) => ({ requests }),
|
|
markTraceIdsLoading: (traceIds: string[]) => ({ traceIds }),
|
|
loadTraceMessagesBatchSuccess: (results: Record<string, TraceMessages>, requestedTraceIds: string[]) => ({
|
|
results,
|
|
requestedTraceIds,
|
|
}),
|
|
loadTraceMessagesBatchFailure: (requestedTraceIds: string[]) => ({ requestedTraceIds }),
|
|
}),
|
|
|
|
reducers({
|
|
messagesByTraceId: [
|
|
{} as Record<string, TraceMessages | null>,
|
|
{
|
|
loadTraceMessagesBatchSuccess: (state, { results, requestedTraceIds }) => {
|
|
const next = { ...state }
|
|
for (const traceId of requestedTraceIds) {
|
|
next[traceId] = results[traceId] ?? {
|
|
lastInput: null,
|
|
lastOutput: null,
|
|
lastInputFallback: null,
|
|
lastOutputFallback: null,
|
|
}
|
|
}
|
|
return next
|
|
},
|
|
loadTraceMessagesBatchFailure: (state, { requestedTraceIds }) => {
|
|
const next = { ...state }
|
|
for (const traceId of requestedTraceIds) {
|
|
next[traceId] = null
|
|
}
|
|
return next
|
|
},
|
|
},
|
|
],
|
|
|
|
loadingTraceIds: [
|
|
new Set<string>(),
|
|
{
|
|
markTraceIdsLoading: (state, { traceIds }) => {
|
|
const next = new Set(state)
|
|
for (const traceId of traceIds) {
|
|
if (traceId) {
|
|
next.add(traceId)
|
|
}
|
|
}
|
|
return next
|
|
},
|
|
loadTraceMessagesBatchSuccess: (state, { requestedTraceIds }) => {
|
|
const next = new Set(state)
|
|
for (const traceId of requestedTraceIds) {
|
|
next.delete(traceId)
|
|
}
|
|
return next
|
|
},
|
|
loadTraceMessagesBatchFailure: (state, { requestedTraceIds }) => {
|
|
const next = new Set(state)
|
|
for (const traceId of requestedTraceIds) {
|
|
next.delete(traceId)
|
|
}
|
|
return next
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
|
|
selectors({
|
|
getTraceMessages: [
|
|
(s) => [s.messagesByTraceId],
|
|
(
|
|
messagesByTraceId: Record<string, TraceMessages | null>
|
|
): ((traceId: string) => TraceMessages | null | undefined) => {
|
|
return (traceId: string) => messagesByTraceId[traceId]
|
|
},
|
|
],
|
|
}),
|
|
|
|
listeners(({ actions, values, cache }) => ({
|
|
ensureTraceMessagesLoaded: ({ requests }) => {
|
|
const uncached = requests.filter(
|
|
(r) => r.id && values.messagesByTraceId[r.id] === undefined && !values.loadingTraceIds.has(r.id)
|
|
)
|
|
|
|
if (uncached.length === 0) {
|
|
return
|
|
}
|
|
|
|
actions.markTraceIdsLoading(uncached.map((r) => r.id))
|
|
|
|
const pendingTraces = cache.pendingTraces as Map<string, TraceLazyLoadRequest>
|
|
for (const r of uncached) {
|
|
pendingTraces.set(r.id, r)
|
|
}
|
|
|
|
if (cache.batchTimer) {
|
|
return
|
|
}
|
|
|
|
cache.batchTimer = setTimeout(async () => {
|
|
const requestedTraces = Array.from(pendingTraces.values())
|
|
pendingTraces.clear()
|
|
cache.batchTimer = null
|
|
|
|
if (requestedTraces.length === 0) {
|
|
return
|
|
}
|
|
|
|
const chunks = chunk(requestedTraces, BATCH_MAX_SIZE)
|
|
|
|
await Promise.allSettled(
|
|
chunks.map(async (batch) => {
|
|
try {
|
|
// Drop IDs that are too long to inline safely or whose
|
|
// createdAt we can't parse into a scan window anchor.
|
|
const safe = batch.flatMap((r) => {
|
|
const createdAtMs = r.createdAt ? Date.parse(r.createdAt) : NaN
|
|
return r.id && r.id.length <= TRACE_ID_MAX_LENGTH && Number.isFinite(createdAtMs)
|
|
? [{ id: r.id, createdAtMs }]
|
|
: []
|
|
})
|
|
// Mark the dropped entries as failed upfront so they
|
|
// aren't silently counted as "successfully loaded with
|
|
// no data" alongside the real response.
|
|
const safeIdSet = new Set(safe.map((s) => s.id))
|
|
const invalidIds = batch.map((r) => r.id).filter((id) => !safeIdSet.has(id))
|
|
if (invalidIds.length > 0) {
|
|
actions.loadTraceMessagesBatchFailure(invalidIds)
|
|
}
|
|
if (safe.length === 0) {
|
|
return
|
|
}
|
|
|
|
// Union window across the batch (min/max createdAt ± buffer).
|
|
// Stays narrow when rows are clustered in time, so Datastore
|
|
// can prune partitions regardless of the UI date filter.
|
|
const bufferMs = BATCH_WINDOW_BUFFER_MINUTES * 60 * 1000
|
|
const createdAtList = safe.map((s) => s.createdAtMs)
|
|
const fromStr = formatInsightsqlDateTime(new Date(Math.min(...createdAtList) - bufferMs))
|
|
const toStr = formatInsightsqlDateTime(new Date(Math.max(...createdAtList) + bufferMs))
|
|
|
|
// Inlining IDs + timestamps rather than using a values dict: we
|
|
// can't combine `{values}` with `{filters}`-style placeholders
|
|
// because parse_select eagerly resolves all placeholders against
|
|
// the values dict before find_placeholders runs.
|
|
// escapeInsightsQLString returns the value already wrapped in single quotes.
|
|
const idList = safe.map((s) => escapeInsightsQLString(s.id)).join(',')
|
|
const query: InsightsQLQuery = {
|
|
kind: NodeKind.InsightsQLQuery,
|
|
query: `
|
|
SELECT
|
|
trace_id,
|
|
anyIf(
|
|
substringUTF8(input_state, 1, ${FIELD_TRUNCATE_CHARS}),
|
|
event = '$ai_trace'
|
|
AND length(input_state) > 0
|
|
) AS last_input,
|
|
anyIf(
|
|
substringUTF8(output_state, 1, ${FIELD_TRUNCATE_CHARS}),
|
|
event = '$ai_trace'
|
|
AND length(output_state) > 0
|
|
) AS last_output,
|
|
argMaxIf(
|
|
substringUTF8(input, 1, ${FIELD_TRUNCATE_CHARS}),
|
|
timestamp,
|
|
event = '$ai_generation'
|
|
AND length(input) > 0
|
|
) AS last_input_fallback,
|
|
argMaxIf(
|
|
substringUTF8(output_choices, 1, ${FIELD_TRUNCATE_CHARS}),
|
|
timestamp,
|
|
event = '$ai_generation'
|
|
AND length(output_choices) > 0
|
|
) AS last_output_fallback
|
|
FROM insights.ai_events AS ai_events
|
|
WHERE event IN ('$ai_trace', '$ai_generation')
|
|
AND timestamp >= toDateTime('${fromStr}', 'UTC')
|
|
AND timestamp <= toDateTime('${toStr}', 'UTC')
|
|
AND trace_id IN (${idList})
|
|
GROUP BY trace_id
|
|
`,
|
|
}
|
|
|
|
const response = await api.query(query)
|
|
const results: Record<string, TraceMessages> = {}
|
|
|
|
for (const row of response.results || []) {
|
|
const [traceId, lastInput, lastOutput, lastInputFallback, lastOutputFallback] = row as [
|
|
string,
|
|
unknown,
|
|
unknown,
|
|
unknown,
|
|
unknown,
|
|
]
|
|
if (traceId) {
|
|
results[traceId] = {
|
|
lastInput: parseTruncatedJson(lastInput),
|
|
lastOutput: parseTruncatedJson(lastOutput),
|
|
lastInputFallback: parseTruncatedJson(lastInputFallback),
|
|
lastOutputFallback: parseTruncatedJson(lastOutputFallback),
|
|
}
|
|
}
|
|
}
|
|
|
|
actions.loadTraceMessagesBatchSuccess(
|
|
results,
|
|
safe.map((s) => s.id)
|
|
)
|
|
} catch (error) {
|
|
console.warn('Error loading trace messages batch', error)
|
|
actions.loadTraceMessagesBatchFailure(batch.map((r) => r.id))
|
|
}
|
|
})
|
|
)
|
|
}, BATCH_DEBOUNCE_MS)
|
|
},
|
|
})),
|
|
|
|
events(({ cache }) => ({
|
|
afterMount: () => {
|
|
cache.pendingTraces = new Map<string, TraceLazyLoadRequest>()
|
|
cache.batchTimer = null
|
|
},
|
|
beforeUnmount: () => {
|
|
if (cache.batchTimer) {
|
|
clearTimeout(cache.batchTimer)
|
|
cache.batchTimer = null
|
|
}
|
|
cache.pendingTraces?.clear?.()
|
|
},
|
|
})),
|
|
])
|
|
|
|
// Parse Datastore-truncated JSON: strict parse, then partial-JSON recovery,
|
|
// then raw string so the normalizer can still show something.
|
|
function parseTruncatedJson(value: unknown): unknown {
|
|
if (typeof value !== 'string') {
|
|
return value
|
|
}
|
|
if (value.length === 0) {
|
|
return null
|
|
}
|
|
try {
|
|
return JSON.parse(value)
|
|
} catch {
|
|
try {
|
|
return parsePartialJSON(value)
|
|
} catch {
|
|
return value
|
|
}
|
|
}
|
|
}
|