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.
203 lines
7.7 KiB
TypeScript
203 lines
7.7 KiB
TypeScript
import { formatDateRangeLabel } from 'lib/components/DateFilter/DateRangePicker/utils'
|
|
import { isValidPropertyFilter } from 'lib/components/PropertyFilters/utils'
|
|
import { dayjs } from 'lib/dayjs'
|
|
import { capitalizeFirstLetter } from 'lib/utils/strings'
|
|
|
|
import { AnyPropertyFilter, FilterLogicalOperator, PropertyFilterType, PropertyOperator } from '~/types'
|
|
|
|
import { LogsViewerFilters } from 'products/logs/frontend/components/LogsViewer/config/types'
|
|
import { DEFAULT_LOGS_SESSION_ID_ATTRIBUTE_KEYS } from 'products/logs/frontend/logsConfigLogic'
|
|
|
|
export function formatFilterGroupValues(filterGroup: Record<string, any> | undefined): string[] {
|
|
const group = filterGroup?.values?.[0]
|
|
if (!group || !('values' in group)) {
|
|
return []
|
|
}
|
|
|
|
return group.values.filter(isValidPropertyFilter).map((filter: AnyPropertyFilter) => {
|
|
const key = filter.key || '?'
|
|
const value = Array.isArray(filter.value) ? filter.value.join(', ') : String(filter.value ?? '')
|
|
const truncatedValue = value.length > 15 ? `${value.slice(0, 15)}...` : value
|
|
return `${key}=${truncatedValue}`
|
|
})
|
|
}
|
|
|
|
export interface FiltersSummaryLine {
|
|
label: string
|
|
value: string
|
|
}
|
|
|
|
export function getFiltersSummaryLines(filters: Record<string, any>): FiltersSummaryLine[] {
|
|
const lines: FiltersSummaryLine[] = []
|
|
|
|
if (filters.dateRange) {
|
|
const label = formatDateRangeLabel(filters.dateRange, Intl.DateTimeFormat().resolvedOptions().timeZone, [])
|
|
lines.push({ label: 'Date range', value: label })
|
|
}
|
|
|
|
if (filters.severityLevels?.length) {
|
|
lines.push({
|
|
label: 'Severity',
|
|
value: filters.severityLevels.map((l: string) => capitalizeFirstLetter(l)).join(', '),
|
|
})
|
|
}
|
|
|
|
if (filters.serviceNames?.length) {
|
|
const maxDisplayed = 3
|
|
const displayed = filters.serviceNames.slice(0, maxDisplayed)
|
|
const remaining = filters.serviceNames.length - displayed.length
|
|
const serviceText = displayed.join(', ')
|
|
lines.push({
|
|
label: filters.serviceNames.length === 1 ? 'Service' : 'Services',
|
|
value: remaining > 0 ? `${serviceText} +${remaining} more` : serviceText,
|
|
})
|
|
}
|
|
|
|
if (filters.searchTerm) {
|
|
const truncated = filters.searchTerm.length > 30 ? `${filters.searchTerm.slice(0, 30)}...` : filters.searchTerm
|
|
lines.push({ label: 'Search', value: `"${truncated}"` })
|
|
}
|
|
|
|
const attributeFilters = formatFilterGroupValues(filters.filterGroup)
|
|
if (attributeFilters.length > 0) {
|
|
lines.push({
|
|
label: attributeFilters.length === 1 ? 'Filter' : 'Filters',
|
|
value: attributeFilters.join(', '),
|
|
})
|
|
}
|
|
|
|
return lines
|
|
}
|
|
|
|
// Mirror of DISTINCT_ID_ATTRIBUTE_KEY_CONVENTIONS in products/logs/backend/models.py — keep the
|
|
// two in sync. Values under these keys render as clickable person links here, and the person
|
|
// Logs tab scopes on them server-side so those logs appear on the person's tab.
|
|
const DISTINCT_ID_KEYS = [
|
|
'distinct.id',
|
|
'distinct_id',
|
|
'distinctId',
|
|
'distinctID',
|
|
'insightsDistinctId',
|
|
'insightsDistinctID',
|
|
'insights_distinct_id',
|
|
'insights.distinct.id',
|
|
'insights.distinct_id',
|
|
]
|
|
const SESSION_ID_KEYS = [
|
|
'session.id',
|
|
'session_id',
|
|
'sessionId',
|
|
'sessionID',
|
|
'$session_id',
|
|
'insightsSessionId',
|
|
'insightsSessionID',
|
|
'insights_session_id',
|
|
'insights.session.id',
|
|
'insights.session_id',
|
|
]
|
|
|
|
function matchesKey(key: string, candidates: string[]): boolean {
|
|
return candidates.some((candidate) => key === candidate || key.endsWith(`.${candidate}`))
|
|
}
|
|
|
|
// Configured keys (the team's `logs_distinct_id_attribute_keys` setting) match exactly;
|
|
// only the built-in convention list gets dot-suffix matching.
|
|
export function isDistinctIdKey(key: string, configuredKeys?: string[]): boolean {
|
|
return (configuredKeys ?? []).includes(key) || matchesKey(key, DISTINCT_ID_KEYS)
|
|
}
|
|
|
|
// Configured keys (the team's `logs_session_id_attribute_keys` setting) match exactly;
|
|
// only the built-in convention list gets dot-suffix matching.
|
|
export function isSessionIdKey(key: string, configuredKeys?: string[]): boolean {
|
|
return (configuredKeys ?? []).includes(key) || matchesKey(key, SESSION_ID_KEYS)
|
|
}
|
|
|
|
export interface SessionIdMatch {
|
|
key: string
|
|
value: string
|
|
source: 'attribute' | 'resource_attribute'
|
|
}
|
|
|
|
export function getSessionIdWithKey(
|
|
attributes: Record<string, unknown> | undefined,
|
|
resourceAttributes: Record<string, unknown> | undefined,
|
|
configuredKeys?: string[]
|
|
): SessionIdMatch | null {
|
|
// Configured keys win over the built-in conventions, in list order: for each key,
|
|
// attributes are checked before resource_attributes, and the first value found wins.
|
|
for (const key of configuredKeys ?? []) {
|
|
const attributeValue = attributes?.[key]
|
|
if (attributeValue) {
|
|
return { key, value: String(attributeValue), source: 'attribute' }
|
|
}
|
|
const resourceAttributeValue = resourceAttributes?.[key]
|
|
if (resourceAttributeValue) {
|
|
return { key, value: String(resourceAttributeValue), source: 'resource_attribute' }
|
|
}
|
|
}
|
|
// Built-in convention fallback only — the configured-key pass already ran above,
|
|
// so isSessionIdKey is deliberately called without configuredKeys here.
|
|
for (const [key, value] of Object.entries(attributes || {})) {
|
|
if (isSessionIdKey(key) && value) {
|
|
return { key, value: String(value), source: 'attribute' }
|
|
}
|
|
}
|
|
for (const [key, value] of Object.entries(resourceAttributes || {})) {
|
|
if (isSessionIdKey(key) && value) {
|
|
return { key, value: String(value), source: 'resource_attribute' }
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
export function getSessionIdFromLogAttributes(
|
|
attributes: Record<string, unknown> | undefined,
|
|
resourceAttributes: Record<string, unknown> | undefined,
|
|
configuredKeys?: string[]
|
|
): string | null {
|
|
return getSessionIdWithKey(attributes, resourceAttributes, configuredKeys)?.value ?? null
|
|
}
|
|
|
|
// Wide enough to cover a session around a single event without drowning it in unrelated logs.
|
|
export const SESSION_LOGS_WINDOW_MINUTES = 30
|
|
|
|
export function buildDateRangeAround(timestamp: string, windowMinutes: number): { date_from: string; date_to: string } {
|
|
const center = dayjs(timestamp)
|
|
return {
|
|
date_from: center.subtract(windowMinutes, 'minute').toISOString(),
|
|
date_to: center.add(windowMinutes, 'minute').toISOString(),
|
|
}
|
|
}
|
|
|
|
// Builds logs viewer filters scoped to one session, for other products surfacing logs
|
|
// (error tracking, session replay). Filters on the team's configured session ID keys
|
|
// (OR across keys, exact match), defaulting to the SDK convention; a timestamp scopes
|
|
// the date range to ±30 minutes so old sessions aren't hidden by the default range.
|
|
export function buildLogsSessionFilters(
|
|
sessionId: string,
|
|
configuredKeys?: string[],
|
|
timestamp?: string
|
|
): Partial<LogsViewerFilters> {
|
|
const keys = configuredKeys?.length ? configuredKeys : DEFAULT_LOGS_SESSION_ID_ATTRIBUTE_KEYS
|
|
const filters: Partial<LogsViewerFilters> = {
|
|
filterGroup: {
|
|
type: FilterLogicalOperator.And,
|
|
values: [
|
|
{
|
|
type: FilterLogicalOperator.Or,
|
|
values: keys.map((key) => ({
|
|
key,
|
|
value: [sessionId],
|
|
operator: PropertyOperator.Exact,
|
|
type: PropertyFilterType.LogAttribute,
|
|
})),
|
|
},
|
|
],
|
|
},
|
|
}
|
|
if (timestamp) {
|
|
filters.dateRange = buildDateRangeAround(timestamp, SESSION_LOGS_WINDOW_MINUTES)
|
|
}
|
|
return filters
|
|
}
|