mirror of
https://github.com/hanzoai/insights.git
synced 2026-10-30 16:15:31 +00:00
The tree was a debranded snapshot of upstream taken around 2026-02-20, and upstream has moved about five months since. This replaces it with upstream master, mechanically re-derived: bin/debrand does the rename, and the pipeline and squashed migrations are restored from the shipped tree. Not a merge. The previous import replaced the tree wholesale in a single commit, so there is no shared content history to merge against. Reproducing the rename on the new tree is what actually converges. What survives: .hanzo/workflows, the Dockerfiles and bin/docker* entrypoints, compose files, LLM.md, and insights/migrations. The squashed initial migration is load-bearing — the live database records it by name, so upstream's ~918 individual migrations cannot come back. Nine products are dropped as unreferenced. Twenty-one more that we do not run are kept anyway because the core app still imports them; unpicking those is a refactor and does not belong in a rebase. This base does not build yet, and the reasons are not in the rename: upstream now wants Python 3.13.13 against our 3.12.12 and Django 5.2 against our 4.2, and the dependency manifests still name upstream packages that we republish under our own names. Those are decisions, not renames.
618 lines
22 KiB
TypeScript
618 lines
22 KiB
TypeScript
import { useActions, useValues } from 'kea'
|
||
import { combineUrl, router } from 'kea-router'
|
||
import { useEffect } from 'react'
|
||
|
||
import { IconFilter } from '@hanzo/icons'
|
||
import { Button, Tag, Link } from '@hanzo/elements'
|
||
|
||
import { Tooltip } from 'lib/elements/Tooltip'
|
||
import { PersonDisplay, PersonIcon } from 'scenes/persons/PersonDisplay'
|
||
import { urls } from 'scenes/urls'
|
||
|
||
import { DataTableNode, DataVisualizationNode } from '~/queries/schema/schema-general'
|
||
import { LLMTrace } from '~/queries/schema/schema-general'
|
||
import { QueryContextColumn } from '~/queries/types'
|
||
import { insightsql, isDataTableNode, isEventsQuery } from '~/queries/utils'
|
||
import { AnyPropertyFilter, PropertyFilterType, PropertyOperator } from '~/types'
|
||
|
||
import { aiObservabilitySharedLogic } from './aiObservabilitySharedLogic'
|
||
import { AIDataLoading } from './components/AIDataLoading'
|
||
import { SentimentBar } from './components/SentimentTag'
|
||
import { LLMMessageDisplay } from './ConversationDisplay/ConversationMessagesDisplay'
|
||
import { EventData, useAIData } from './hooks/useAIData'
|
||
import { llmGenerationSentimentLazyLoaderLogic } from './llmGenerationSentimentLazyLoaderLogic'
|
||
import { llmPersonsLazyLoaderLogic } from './llmPersonsLazyLoaderLogic'
|
||
import { normalizeMessages } from './messageNormalization'
|
||
import type { GenerationSentimentLookup } from './sentimentQueries'
|
||
import { GENERATION_SENTIMENT_SELECT } from './sentimentResults'
|
||
import { traceReviewsLazyLoaderLogic } from './traceReviews/traceReviewsLazyLoaderLogic'
|
||
import { TraceReviewValue } from './traceReviews/TraceReviewValue'
|
||
import { CompatMessage } from './types'
|
||
import { parseJSONPreview } from './utils'
|
||
|
||
const truncateValue = (value: string): string => {
|
||
if (value.length > 8) {
|
||
return `${value.substring(0, 4)}...${value.substring(value.length - 4)}`
|
||
}
|
||
return value
|
||
}
|
||
|
||
// Person types and utilities for filter functionality
|
||
export interface PersonData {
|
||
distinct_id?: string
|
||
properties?: Record<string, unknown>
|
||
}
|
||
|
||
export type FilterIdentifier =
|
||
| { type: 'email'; value: string }
|
||
| { type: 'username'; value: string }
|
||
| { type: 'distinct_id'; value: string }
|
||
|
||
export function getFilterIdentifier(person: PersonData | null | undefined): FilterIdentifier | null {
|
||
if (!person) {
|
||
return null
|
||
}
|
||
|
||
const email = typeof person.properties?.email === 'string' ? person.properties.email : undefined
|
||
const username = typeof person.properties?.username === 'string' ? person.properties.username : undefined
|
||
const distinctId = person.distinct_id
|
||
|
||
if (email) {
|
||
return { type: 'email', value: email }
|
||
}
|
||
|
||
if (username) {
|
||
return { type: 'username', value: username }
|
||
}
|
||
|
||
if (distinctId) {
|
||
return { type: 'distinct_id', value: distinctId }
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
export function createPersonFilter(filterIdentifier: FilterIdentifier): AnyPropertyFilter {
|
||
if (filterIdentifier.type === 'distinct_id') {
|
||
return {
|
||
type: PropertyFilterType.InsightsQL,
|
||
key: insightsql`distinct_id == ${filterIdentifier.value}`,
|
||
}
|
||
}
|
||
|
||
return {
|
||
type: PropertyFilterType.Person,
|
||
key: filterIdentifier.type,
|
||
operator: PropertyOperator.Exact,
|
||
value: filterIdentifier.value,
|
||
}
|
||
}
|
||
|
||
export function getTracesUrlWithPersonFilter(
|
||
filterIdentifier: FilterIdentifier,
|
||
dateRange?: { dateFrom: string | null; dateTo: string | null }
|
||
): string {
|
||
const filter = createPersonFilter(filterIdentifier)
|
||
return combineUrl(urls.aiObservabilityTraces(), {
|
||
filters: [filter],
|
||
date_from: dateRange?.dateFrom ?? undefined,
|
||
date_to: dateRange?.dateTo ?? undefined,
|
||
}).url
|
||
}
|
||
|
||
function PersonColumnCell({ person }: { person: PersonData | null | undefined }): JSX.Element {
|
||
const { setPropertyFilters } = useActions(aiObservabilitySharedLogic)
|
||
const { propertyFilters } = useValues(aiObservabilitySharedLogic)
|
||
|
||
const filterIdentifier = getFilterIdentifier(person)
|
||
|
||
const handleFilterByPerson = (e: React.MouseEvent): void => {
|
||
e.stopPropagation()
|
||
|
||
if (!filterIdentifier) {
|
||
return
|
||
}
|
||
|
||
const newFilter = createPersonFilter(filterIdentifier)
|
||
const filterExists = propertyFilters.some((f) => {
|
||
if (filterIdentifier.type === 'distinct_id') {
|
||
return f.type === PropertyFilterType.InsightsQL && f.key === newFilter.key
|
||
}
|
||
return (
|
||
f.type === PropertyFilterType.Person &&
|
||
f.key === filterIdentifier.type &&
|
||
'value' in f &&
|
||
f.value === filterIdentifier.value
|
||
)
|
||
})
|
||
|
||
if (!filterExists) {
|
||
setPropertyFilters([...propertyFilters, newFilter])
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="flex items-center gap-1">
|
||
<PersonDisplay person={person ?? undefined} withIcon noPopover={false} />
|
||
|
||
{filterIdentifier && (
|
||
<Tooltip title={`Filter by ${filterIdentifier.value}`}>
|
||
<Button size="xsmall" icon={<IconFilter />} onClick={handleFilterByPerson} noPadding />
|
||
</Tooltip>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function PersonColumnCellWithRedirect({ person }: { person: PersonData | null | undefined }): JSX.Element {
|
||
const { push } = useActions(router)
|
||
const { dateFilter } = useValues(aiObservabilitySharedLogic)
|
||
const filterIdentifier = getFilterIdentifier(person)
|
||
|
||
const handleFilterAndRedirect = (e: React.MouseEvent): void => {
|
||
e.stopPropagation()
|
||
|
||
if (!filterIdentifier) {
|
||
return
|
||
}
|
||
|
||
const url = getTracesUrlWithPersonFilter(filterIdentifier, dateFilter)
|
||
push(url)
|
||
}
|
||
|
||
return (
|
||
<div className="flex items-center gap-1">
|
||
<PersonDisplay person={person ?? undefined} withIcon noPopover={false} />
|
||
|
||
{filterIdentifier && (
|
||
<Tooltip title={`View traces for ${filterIdentifier.value}`}>
|
||
<Button size="xsmall" icon={<IconFilter />} onClick={handleFilterAndRedirect} noPadding />
|
||
</Tooltip>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export function LazyPersonColumnCell({ distinctId }: { distinctId: string }): JSX.Element {
|
||
const { personsCache, currentTeamId } = useValues(llmPersonsLazyLoaderLogic)
|
||
const { ensurePersonLoaded } = useActions(llmPersonsLazyLoaderLogic)
|
||
|
||
const cached = personsCache[distinctId]
|
||
|
||
useEffect(() => {
|
||
if (currentTeamId && cached === undefined) {
|
||
ensurePersonLoaded(distinctId)
|
||
}
|
||
}, [currentTeamId, cached, distinctId, ensurePersonLoaded])
|
||
|
||
const personData: PersonData = cached
|
||
? { distinct_id: cached.distinct_id, properties: cached.properties }
|
||
: { distinct_id: distinctId }
|
||
|
||
return <PersonColumnCell person={personData} />
|
||
}
|
||
|
||
// Avatar only (no name) for inline use beside a title; a click still opens the
|
||
// full person popover. Shares the lazy person loader with LazyPersonColumnCell.
|
||
export function LazyPersonAvatar({ distinctId }: { distinctId: string }): JSX.Element {
|
||
const { personsCache, currentTeamId } = useValues(llmPersonsLazyLoaderLogic)
|
||
const { ensurePersonLoaded } = useActions(llmPersonsLazyLoaderLogic)
|
||
|
||
const cached = personsCache[distinctId]
|
||
|
||
useEffect(() => {
|
||
if (currentTeamId && cached === undefined) {
|
||
ensurePersonLoaded(distinctId)
|
||
}
|
||
}, [currentTeamId, cached, distinctId, ensurePersonLoaded])
|
||
|
||
const personData: PersonData = cached
|
||
? { distinct_id: cached.distinct_id, properties: cached.properties }
|
||
: { distinct_id: distinctId }
|
||
|
||
return (
|
||
<PersonDisplay person={personData}>
|
||
<PersonIcon person={personData} size="md" />
|
||
</PersonDisplay>
|
||
)
|
||
}
|
||
|
||
function getStringColumnValue(record: unknown[], columns: string[], column: string): string | null {
|
||
const index = columns.findIndex((col) => col === column)
|
||
if (index < 0) {
|
||
return null
|
||
}
|
||
|
||
const value = record[index]
|
||
return typeof value === 'string' && value ? value : null
|
||
}
|
||
|
||
function getGenerationSentimentLookup(record: unknown, query: DataTableNode): GenerationSentimentLookup | null {
|
||
if (!Array.isArray(record) || !isEventsQuery(query.source)) {
|
||
return null
|
||
}
|
||
|
||
const columns = query.source.select ?? []
|
||
const eventId = getStringColumnValue(record, columns, 'uuid')
|
||
const traceId = getStringColumnValue(record, columns, 'properties.$ai_trace_id')
|
||
|
||
if (!eventId || !traceId) {
|
||
return null
|
||
}
|
||
|
||
const generationId = getStringColumnValue(record, columns, 'properties.$ai_generation_id')
|
||
const generationIds = generationId && generationId !== eventId ? [eventId, generationId] : [eventId]
|
||
|
||
return {
|
||
key: eventId,
|
||
traceId,
|
||
generationIds,
|
||
}
|
||
}
|
||
|
||
function LazyGenerationSentimentCell({ lookup }: { lookup: GenerationSentimentLookup }): JSX.Element {
|
||
const { getGenerationSentiment, isGenerationLoading } = useValues(llmGenerationSentimentLazyLoaderLogic)
|
||
const { ensureGenerationSentimentLoaded } = useActions(llmGenerationSentimentLazyLoaderLogic)
|
||
|
||
const lookupKey = lookup.key
|
||
const lookupTraceId = lookup.traceId
|
||
const lookupGenerationIdsKey = lookup.generationIds.join('\0')
|
||
const cached = getGenerationSentiment(lookupKey)
|
||
const loading = isGenerationLoading(lookupKey)
|
||
|
||
useEffect(() => {
|
||
if (cached === undefined && !loading) {
|
||
ensureGenerationSentimentLoaded({
|
||
key: lookupKey,
|
||
traceId: lookupTraceId,
|
||
generationIds: lookupGenerationIdsKey ? lookupGenerationIdsKey.split('\0') : [],
|
||
})
|
||
}
|
||
}, [cached, ensureGenerationSentimentLoaded, loading, lookupGenerationIdsKey, lookupKey, lookupTraceId])
|
||
|
||
if (loading || cached === undefined) {
|
||
return <AIDataLoading variant="inline" />
|
||
}
|
||
|
||
if (cached === null) {
|
||
return <>–</>
|
||
}
|
||
|
||
return <SentimentBar label={cached.label} score={cached.score} size="full" messages={cached.messages} />
|
||
}
|
||
|
||
function LazyTraceReviewColumnCell({ traceId }: { traceId: string }): JSX.Element {
|
||
const { getTraceReview, isTraceLoading, didTraceReviewLoadFail } = useValues(traceReviewsLazyLoaderLogic)
|
||
const { ensureReviewsLoaded } = useActions(traceReviewsLazyLoaderLogic)
|
||
const cached = typeof getTraceReview === 'function' ? getTraceReview(traceId) : undefined
|
||
const loading = typeof isTraceLoading === 'function' ? isTraceLoading(traceId) : false
|
||
const failed = typeof didTraceReviewLoadFail === 'function' ? didTraceReviewLoadFail(traceId) : false
|
||
|
||
useEffect(() => {
|
||
if (!traceId || cached !== undefined || loading || failed) {
|
||
return
|
||
}
|
||
|
||
ensureReviewsLoaded([traceId])
|
||
}, [cached, ensureReviewsLoaded, failed, loading, traceId])
|
||
|
||
if (loading || cached === undefined) {
|
||
if (failed) {
|
||
return (
|
||
<Tooltip title="Failed to load review status.">
|
||
<Button type="tertiary" size="xsmall" onClick={() => ensureReviewsLoaded([traceId])}>
|
||
Retry
|
||
</Button>
|
||
</Tooltip>
|
||
)
|
||
}
|
||
|
||
return <AIDataLoading variant="inline" />
|
||
}
|
||
|
||
if (cached === null) {
|
||
return <>–</>
|
||
}
|
||
|
||
return <TraceReviewValue review={cached} />
|
||
}
|
||
|
||
function AIInputCell({ eventData }: { eventData: EventData }): JSX.Element {
|
||
const { input, isLoading } = useAIData(eventData)
|
||
|
||
if (isLoading) {
|
||
return <AIDataLoading variant="inline" />
|
||
}
|
||
|
||
let inputNormalized: CompatMessage[] | undefined
|
||
try {
|
||
const parsed = parseJSONPreview(input)
|
||
inputNormalized = normalizeMessages(parsed, 'user').messages
|
||
} catch (e) {
|
||
console.warn('Error normalizing properties.$ai_input', e)
|
||
}
|
||
|
||
if (!inputNormalized?.length) {
|
||
return <>–</>
|
||
}
|
||
|
||
return <LLMMessageDisplay message={inputNormalized.at(-1)!} isOutput={false} minimal />
|
||
}
|
||
|
||
function AIOutputCell({ eventData }: { eventData: EventData }): JSX.Element {
|
||
const { output, isLoading } = useAIData(eventData)
|
||
|
||
if (isLoading) {
|
||
return <AIDataLoading variant="inline" />
|
||
}
|
||
|
||
let outputNormalized: CompatMessage[] | undefined
|
||
try {
|
||
const parsed = parseJSONPreview(output)
|
||
outputNormalized = normalizeMessages(parsed, 'assistant').messages
|
||
} catch (e) {
|
||
console.warn('Error normalizing properties.$ai_output_choices', e)
|
||
}
|
||
|
||
if (!outputNormalized?.length) {
|
||
return <>–</>
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
{outputNormalized.map((message, index) => (
|
||
<LLMMessageDisplay key={index} message={message} isOutput={true} minimal />
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const getEventData = (record: unknown, query?: DataTableNode | DataVisualizationNode): EventData | undefined => {
|
||
// Object format (TracesQuery results)
|
||
if (record && typeof record === 'object' && !Array.isArray(record) && 'uuid' in record) {
|
||
const uuid = record.uuid
|
||
if (typeof uuid !== 'string') {
|
||
return undefined
|
||
}
|
||
const props = 'properties' in record && typeof record.properties === 'object' ? record.properties : null
|
||
return {
|
||
uuid,
|
||
input: (props as Record<string, unknown> | null)?.$ai_input,
|
||
output: (props as Record<string, unknown> | null)?.$ai_output_choices,
|
||
}
|
||
}
|
||
|
||
// Array format (EventsQuery results)
|
||
if (Array.isArray(record) && isDataTableNode(query) && isEventsQuery(query.source)) {
|
||
const select = query.source.select ?? []
|
||
const uuidIdx = select.findIndex((c) => c === 'uuid')
|
||
const inputIdx = select.findIndex((c) => c === 'properties.$ai_input' || c === 'properties.$ai_input[-1]')
|
||
const outputIdx = select.findIndex((c) => c === 'properties.$ai_output_choices')
|
||
|
||
const uuid = record[uuidIdx]
|
||
if (typeof uuid !== 'string') {
|
||
return undefined
|
||
}
|
||
|
||
return {
|
||
uuid,
|
||
input: inputIdx >= 0 ? record[inputIdx] : undefined,
|
||
output: outputIdx >= 0 ? record[outputIdx] : undefined,
|
||
}
|
||
}
|
||
|
||
return undefined
|
||
}
|
||
|
||
const MAX_VISIBLE_TOOLS = 5
|
||
|
||
export function ToolsDisplay({ tools }: { tools: string[] | undefined | null }): JSX.Element {
|
||
if (!tools || tools.length === 0) {
|
||
return <>–</>
|
||
}
|
||
const visible = tools.slice(0, MAX_VISIBLE_TOOLS)
|
||
const remaining = tools.length - MAX_VISIBLE_TOOLS
|
||
return (
|
||
<div className="flex flex-wrap gap-1">
|
||
{visible.map((tool) => (
|
||
<Tag key={tool} type="muted">
|
||
{tool}
|
||
</Tag>
|
||
))}
|
||
{remaining > 0 && (
|
||
<Tooltip title={tools.slice(MAX_VISIBLE_TOOLS).join(', ')}>
|
||
<Tag type="muted">+{remaining} more</Tag>
|
||
</Tooltip>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export const aiObservabilityColumnRenderers: Record<string, QueryContextColumn> = {
|
||
'properties.$ai_input[-1]': {
|
||
title: 'Input',
|
||
render: ({ record, query }) => {
|
||
const eventData = getEventData(record, query)
|
||
if (!eventData) {
|
||
return <>–</>
|
||
}
|
||
return <AIInputCell eventData={eventData} />
|
||
},
|
||
},
|
||
'properties.$ai_input': {
|
||
title: 'Input (full)',
|
||
render: ({ record, query }) => {
|
||
const eventData = getEventData(record, query)
|
||
if (!eventData) {
|
||
return <>–</>
|
||
}
|
||
return <AIInputCell eventData={eventData} />
|
||
},
|
||
},
|
||
'properties.$ai_output_choices': {
|
||
title: 'Output',
|
||
render: ({ record, query }) => {
|
||
const eventData = getEventData(record, query)
|
||
if (!eventData) {
|
||
return <>–</>
|
||
}
|
||
return <AIOutputCell eventData={eventData} />
|
||
},
|
||
},
|
||
'properties.$ai_trace_id': {
|
||
title: 'Trace ID',
|
||
render: ({ value }) => {
|
||
if (!value || typeof value !== 'string') {
|
||
return null
|
||
}
|
||
|
||
const visualValue = truncateValue(value)
|
||
|
||
return (
|
||
<Tooltip title={value}>
|
||
<Link to={urls.aiObservabilityTrace(value)} data-attr="generation-trace-link">
|
||
{visualValue}
|
||
</Link>
|
||
</Tooltip>
|
||
)
|
||
},
|
||
},
|
||
person: {
|
||
title: 'Person',
|
||
render: ({ value, record, query }) => {
|
||
// Handle TracesQuery results with lazy loading: person is null, distinctId is available
|
||
if (!value && record && typeof record === 'object' && !Array.isArray(record)) {
|
||
const traceRecord = record as LLMTrace
|
||
if (traceRecord.distinctId) {
|
||
return <LazyPersonColumnCell distinctId={traceRecord.distinctId} />
|
||
}
|
||
}
|
||
|
||
// Handle object format (TracesQuery results - LLMTracePerson, for backwards compat)
|
||
if (value && typeof value === 'object' && !Array.isArray(value) && 'distinct_id' in value) {
|
||
return <PersonColumnCell person={value as PersonData} />
|
||
}
|
||
|
||
// Handle array format (EventsQuery results) - extract person from array by column index
|
||
if (Array.isArray(record) && isDataTableNode(query) && isEventsQuery(query.source)) {
|
||
const select = query.source.select ?? []
|
||
const personIdx = select.findIndex((c) => c === 'person')
|
||
|
||
if (personIdx >= 0) {
|
||
const personValue = record[personIdx]
|
||
|
||
// Person data from EventsQuery comes as a tuple [distinct_id, created_at, properties_json]
|
||
if (Array.isArray(personValue) && personValue.length >= 3) {
|
||
const [distinctId, , propertiesJson] = personValue
|
||
let properties: Record<string, unknown> = {}
|
||
|
||
try {
|
||
properties = typeof propertiesJson === 'string' ? JSON.parse(propertiesJson) : {}
|
||
} catch {
|
||
// Ignore parsing errors
|
||
}
|
||
|
||
return <PersonColumnCell person={{ distinct_id: distinctId, properties }} />
|
||
}
|
||
}
|
||
}
|
||
|
||
return <PersonColumnCell person={null} />
|
||
},
|
||
},
|
||
// Uses __llm_sentiment to avoid collision with user-defined 'sentiment' columns in SQL queries
|
||
__llm_sentiment: {
|
||
title: 'Sentiment',
|
||
render: ({ record }) => {
|
||
if (!record || typeof record !== 'object' || Array.isArray(record)) {
|
||
return <>–</>
|
||
}
|
||
const traceRecord = record as LLMTrace
|
||
if (!traceRecord.sentiment) {
|
||
return <>–</>
|
||
}
|
||
return (
|
||
<SentimentBar
|
||
label={traceRecord.sentiment.label}
|
||
score={traceRecord.sentiment.score}
|
||
size="full"
|
||
messages={traceRecord.sentiment.messages}
|
||
/>
|
||
)
|
||
},
|
||
},
|
||
[GENERATION_SENTIMENT_SELECT]: {
|
||
title: 'Sentiment',
|
||
render: ({ record, query }) => {
|
||
if (!isDataTableNode(query)) {
|
||
return <>–</>
|
||
}
|
||
|
||
const lookup = getGenerationSentimentLookup(record, query)
|
||
return lookup ? <LazyGenerationSentimentCell lookup={lookup} /> : <>–</>
|
||
},
|
||
},
|
||
'properties.$ai_tools_called': {
|
||
title: 'Tools',
|
||
render: ({ value }) => {
|
||
if (!value || typeof value !== 'string') {
|
||
return <>–</>
|
||
}
|
||
const tools = [
|
||
...new Set(
|
||
value
|
||
.split(',')
|
||
.map((t) => t.trim())
|
||
.filter(Boolean)
|
||
),
|
||
]
|
||
return <ToolsDisplay tools={tools} />
|
||
},
|
||
},
|
||
// Uses __llm_tools to avoid collision with user-defined 'tools' columns in SQL queries
|
||
__llm_tools: {
|
||
title: 'Tools',
|
||
render: ({ record }) => {
|
||
const row = record as LLMTrace
|
||
return <ToolsDisplay tools={row.tools} />
|
||
},
|
||
},
|
||
review: {
|
||
title: 'Review',
|
||
render: ({ record }) => {
|
||
if (!record || typeof record !== 'object' || Array.isArray(record)) {
|
||
return <>–</>
|
||
}
|
||
|
||
const traceRecord = record as LLMTrace
|
||
if (!traceRecord.id) {
|
||
return <>–</>
|
||
}
|
||
|
||
return <LazyTraceReviewColumnCell traceId={traceRecord.id} />
|
||
},
|
||
},
|
||
// LLM person column for Users tab - clicking filter redirects to traces page
|
||
// Uses __llm_person to avoid collision with user-defined 'user' columns in SQL queries
|
||
__llm_person: {
|
||
title: 'Person',
|
||
render: ({ value }) => {
|
||
// User data from InsightsQL query comes as a tuple [distinct_id, created_at, properties_json]
|
||
if (Array.isArray(value) && value.length >= 3) {
|
||
const [distinctId, , propertiesJson] = value
|
||
let properties: Record<string, unknown> = {}
|
||
|
||
try {
|
||
properties = typeof propertiesJson === 'string' ? JSON.parse(propertiesJson) : {}
|
||
} catch {
|
||
// Ignore parsing errors
|
||
}
|
||
|
||
return <PersonColumnCellWithRedirect person={{ distinct_id: distinctId, properties }} />
|
||
}
|
||
|
||
return <PersonColumnCellWithRedirect person={null} />
|
||
},
|
||
},
|
||
}
|