From c39c9a931bca6ff83fc7c7efa169d38c70f40b19 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sat, 15 Nov 2025 19:34:47 -0800 Subject: [PATCH 1/4] Add live tail feature to logs --- bin/clickhouse-logs.sql | 30 ++- .../lemon-ui-icons2--shelf-p--dark.png | Bin 12572 -> 13034 bytes .../lemon-ui-icons2--shelf-p--light.png | Bin 11791 -> 4734 bytes .../lib/lemon-ui/LemonTable/LemonTable.scss | 14 + .../lib/lemon-ui/LemonTable/LemonTable.tsx | 5 +- .../src/lib/lemon-ui/LemonTable/TableRow.tsx | 2 +- frontend/src/lib/lemon-ui/icons/icons.tsx | 13 + frontend/src/queries/schema.json | 10 + frontend/src/queries/schema/schema-general.ts | 4 + posthog/hogql/database/database.py | 3 +- posthog/hogql/database/schema/logs.py | 21 ++ posthog/schema.py | 1 + products/logs/backend/api.py | 10 + products/logs/backend/logs_query_runner.py | 13 +- products/logs/frontend/LogsScene.tsx | 23 +- products/logs/frontend/logsLogic.tsx | 246 +++++++++++++++++- 16 files changed, 378 insertions(+), 17 deletions(-) diff --git a/bin/clickhouse-logs.sql b/bin/clickhouse-logs.sql index a0bcddf557a60..8f12e19072674 100644 --- a/bin/clickhouse-logs.sql +++ b/bin/clickhouse-logs.sql @@ -38,6 +38,7 @@ CREATE OR REPLACE TABLE logs31 INDEX idx_attributes_str_values mapValues(attributes_map_str) TYPE bloom_filter(0.001) GRANULARITY 1, INDEX idx_mat_body_ipv4_matches mat_body_ipv4_matches TYPE bloom_filter(0.01) GRANULARITY 1, INDEX idx_body_ngram3 body TYPE ngrambf_v1(3, 25000, 2, 0) GRANULARITY 1, + INDEX idx_observed_minmax observed_timestamp TYPE minmax GRANULARITY 1, PROJECTION projection_aggregate_counts ( SELECT @@ -180,6 +181,33 @@ mapSort(mapFilter((k, v) -> isNotNull(v), mapApply((k,v) -> (concat(k, '__float' mapSort(mapFilter((k, v) -> isNotNull(v), mapApply((k,v) -> (concat(k, '__datetime'), parseDateTimeBestEffortOrNull(JSONExtract(v, 'String'), 6)), attributes))) as attributes_map_datetime, mapSort(resource_attributes) as resource_attributes, toInt32OrZero(_headers.value[indexOf(_headers.name, 'team_id')]) as team_id -FROM kafka_logs_avro; +FROM kafka_logs_avro settings min_insert_block_size_rows=0, min_insert_block_size_bytes=0; + +create or replace table logs_kafka_metrics +( + `_partition` UInt32, + `_topic` String, + `max_offset` SimpleAggregateFunction(max, UInt64), + `max_observed_timestamp` SimpleAggregateFunction(max, DateTime64(9)), + `max_timestamp` SimpleAggregateFunction(max, DateTime64(9)), + `max_created_at` SimpleAggregateFunction(max, DateTime64(9)), + `max_lag` SimpleAggregateFunction(max, UInt64) +) +ENGINE = MergeTree +ORDER BY (_topic, _partition); + +drop view if exists kafka_logs_avro_kafka_metrics_mv; +CREATE MATERIALIZED VIEW kafka_logs_avro_kafka_metrics_mv TO logs_kafka_metrics +AS + SELECT + _partition, + _topic, + maxSimpleState(_offset) as max_offset, + maxSimpleState(observed_timestamp) as max_observed_timestamp, + maxSimpleState(timestamp) as max_timestamp, + maxSimpleState(now()) as max_created_at, + maxSimpleState(now() - observed_timestamp) as max_lag + FROM kafka_logs_avro + group by _partition, _topic; select 'clickhouse logs tables initialised successfully!'; diff --git a/frontend/__snapshots__/lemon-ui-icons2--shelf-p--dark.png b/frontend/__snapshots__/lemon-ui-icons2--shelf-p--dark.png index e917f6ff4504b0f6cd5bd22d2305806bab105c82..3254c7f7c53b7bf40c78e52d684e2327b0add9bb 100644 GIT binary patch literal 13034 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 OG$lE8*{a7Dq5lK25&)q9 literal 12572 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 diff --git a/frontend/__snapshots__/lemon-ui-icons2--shelf-p--light.png b/frontend/__snapshots__/lemon-ui-icons2--shelf-p--light.png index f38a95d03bb8841fa8b638a38d536c92896da914..88d6d6bdb10c1f53727b8e4628b2fa8f84fa61ac 100644 GIT binary patch literal 4734 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 K0#_5>+tQ)(Z+ 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­l0j^+2N%E~kF5&^#0#wI7z1+}IPm_rKICEP+sZSL>i0!_n`FQIkr z^kn8DaH9!o8*CBab~V-ykWn*VUWHbzV_{+{C5xE6dWXo1+CJO-#S2L3rpIgG^}tY? zOhLOC3h7_(hxAu8f%3iHe_0*kUIjw}G7S?J=H}>FSes+zL0!f$7IUe%HPmNinMz1B zP1n0U<%o%k8wEerUx3l@--%vuOQ}{tW#ubvZEeW^NO5}lSrD>ek9tf&21i3agVSeV z024`(5bOXmn)QKs!R!gT#fkXE=}>Dc8d74|_67u@?U*wd)4ax^wFSc-_AG{_qYzrS zYp(;|0b(9Ve+b$8 zCQibr7BrBNf~R*3A3=uzdmtqxB`mB5*g%Sy$7@&v!<3(0i)4JShNgXWG78Kl!etD) zfvBM1*wB!}pYckF3SC{@yHtPRVd;y@NKMgfKToykge$Cb z_wOS|TdwXLSvffXY;Y+$Ns6(Fh^YYud|vBBFb2-);&d%2Nl?^xl}f?&?va~jIUBwH z2vFMDldM|}eT)YvA#rb?Y$cdn5c`n>PGy3QE5Vq>s zN@4QyQ9m$v;CuP_l0T?>VP0bBCKzkbEY!X?mML+(l4`)GlEn(6wK|9I_!XGdOCA^g z1Cc?T&oT z#Y9%i-{8L4aiwQq7=XE*X8lnPaye=LyOyu_9Ru$O1h>tjkPA<xZqSZ*28Fqk zgh&J33O*P*&x&rNiMwq?o|Royo?V3(+SWtg$MnYCrz2gLll=I1O{S; e=?V_h1+qB*a&TBP$p|LA5xECS(na_6eg6lEQ)#LI diff --git a/frontend/src/lib/lemon-ui/LemonTable/LemonTable.scss b/frontend/src/lib/lemon-ui/LemonTable/LemonTable.scss index 02603e2600af4..c773155d70a25 100644 --- a/frontend/src/lib/lemon-ui/LemonTable/LemonTable.scss +++ b/frontend/src/lib/lemon-ui/LemonTable/LemonTable.scss @@ -1,3 +1,13 @@ +@keyframes LemonTable__row__highlight__animation { + 0% { + background-color: var(--color-accent-highlight-secondary); + } + + 100% { + background-color: transparent; + } +} + .LemonTable { --row-base-height: auto; --row-horizontal-padding: 0.5rem; @@ -163,6 +173,10 @@ } } + &.LemonTable__row--status-highlight-new { + animation: LemonTable__row__highlight__animation 1.5s ease-out; + } + &:not(.LemonTable__expansion) { > td { // Make spacing of buttons tighter in tables by adding negative vertical margin diff --git a/frontend/src/lib/lemon-ui/LemonTable/LemonTable.tsx b/frontend/src/lib/lemon-ui/LemonTable/LemonTable.tsx index acccb3b518e44..133c0a9ec82d0 100644 --- a/frontend/src/lib/lemon-ui/LemonTable/LemonTable.tsx +++ b/frontend/src/lib/lemon-ui/LemonTable/LemonTable.tsx @@ -32,7 +32,10 @@ export interface LemonTableProps> { /** Color to mark each row with. */ rowRibbonColor?: string | ((record: T, rowIndex: number) => string | null | undefined) /** Status of each row. Defaults no status. */ - rowStatus?: 'highlighted' | ((record: T, rowIndex: number) => 'highlighted' | null) + rowStatus?: + | 'highlighted' + | 'highlight-new' + | ((record: T, rowIndex: number) => 'highlighted' | 'highlight-new' | null) /** Function that for each row determines what props should its `tr` element have based on the row's record. */ onRow?: (record: T, index: number) => Omit, 'key'> /** How tall should rows be. The default value is `"middle"`. */ diff --git a/frontend/src/lib/lemon-ui/LemonTable/TableRow.tsx b/frontend/src/lib/lemon-ui/LemonTable/TableRow.tsx index 53321477cc489..98ffb87e4d9e6 100644 --- a/frontend/src/lib/lemon-ui/LemonTable/TableRow.tsx +++ b/frontend/src/lib/lemon-ui/LemonTable/TableRow.tsx @@ -14,7 +14,7 @@ export interface TableRowProps> { rowKeyDetermined: string | number rowClassNameDetermined: string | null | undefined rowRibbonColorDetermined: string | null | undefined - rowStatusDetermined: 'highlighted' | null | undefined + rowStatusDetermined: 'highlighted' | 'highlight-new' | null | undefined columnGroups: LemonTableColumnGroup[] onRow: ((record: T, index: number) => Omit, 'key'>) | undefined expandable: ExpandableConfig | undefined diff --git a/frontend/src/lib/lemon-ui/icons/icons.tsx b/frontend/src/lib/lemon-ui/icons/icons.tsx index dbb5024454f1e..2b03cde2e43d7 100644 --- a/frontend/src/lib/lemon-ui/icons/icons.tsx +++ b/frontend/src/lib/lemon-ui/icons/icons.tsx @@ -1150,6 +1150,19 @@ export function IconPlayCircle(props: LemonIconProps): JSX.Element { ) } +export function IconPauseCircle(props: LemonIconProps): JSX.Element { + return ( + + + + + + ) +} + export function IconSkipBackward(props: LemonIconProps): JSX.Element { return ( diff --git a/frontend/src/queries/schema.json b/frontend/src/queries/schema.json index 839906ce1c72f..10534fe731f48 100644 --- a/frontend/src/queries/schema.json +++ b/frontend/src/queries/schema.json @@ -17517,6 +17517,13 @@ "level": { "$ref": "#/definitions/LogSeverityLevel" }, + "live_logs_checkpoint": { + "format": "date-time", + "type": "string" + }, + "new": { + "type": "boolean" + }, "observed_timestamp": { "format": "date-time", "type": "string" @@ -17602,6 +17609,9 @@ "limit": { "$ref": "#/definitions/integer" }, + "liveLogsCheckpoint": { + "type": "string" + }, "modifiers": { "$ref": "#/definitions/HogQLQueryModifiers", "description": "Modifiers used when performing the query" diff --git a/frontend/src/queries/schema/schema-general.ts b/frontend/src/queries/schema/schema-general.ts index c17720df7b262..9c13e5915e8ef 100644 --- a/frontend/src/queries/schema/schema-general.ts +++ b/frontend/src/queries/schema/schema-general.ts @@ -2574,6 +2574,9 @@ export interface LogMessage { resource_attributes: any instrumentation_scope: string event_name: string + /** @format date-time */ + live_logs_checkpoint?: string + new?: boolean } export interface LogsQuery extends DataNode { @@ -2586,6 +2589,7 @@ export interface LogsQuery extends DataNode { severityLevels: LogSeverityLevel[] filterGroup: PropertyGroupFilter serviceNames: string[] + liveLogsCheckpoint?: string } export interface LogsQueryResponse extends AnalyticsQueryResponseBase { diff --git a/posthog/hogql/database/database.py b/posthog/hogql/database/database.py index 88eabfd7ce307..cef2642ac9c1a 100644 --- a/posthog/hogql/database/database.py +++ b/posthog/hogql/database/database.py @@ -71,7 +71,7 @@ LogEntriesTable, ReplayConsoleLogsLogEntriesTable, ) -from posthog.hogql.database.schema.logs import LogsTable +from posthog.hogql.database.schema.logs import LogsKafkaMetricsTable, LogsTable from posthog.hogql.database.schema.numbers import NumbersTable from posthog.hogql.database.schema.person_distinct_id_overrides import ( PersonDistinctIdOverridesTable, @@ -191,6 +191,7 @@ class Database(BaseModel): "document_embeddings": TableNode(name="document_embeddings", table=DocumentEmbeddingsTable()), "pg_embeddings": TableNode(name="pg_embeddings", table=PgEmbeddingsTable()), "logs": TableNode(name="logs", table=LogsTable()), + "logs_kafka_metrics": TableNode(name="logs_kafka_metrics", table=LogsKafkaMetricsTable()), "numbers": TableNode(name="numbers", table=NumbersTable()), "system": SystemTables(), # This is a `TableNode` already, refer to implementation # Web analytics pre-aggregated tables (internal use only) diff --git a/posthog/hogql/database/schema/logs.py b/posthog/hogql/database/schema/logs.py index 5c2bbf6724767..0efe2b0b5b850 100644 --- a/posthog/hogql/database/schema/logs.py +++ b/posthog/hogql/database/schema/logs.py @@ -1,4 +1,5 @@ from posthog.hogql.database.models import ( + DANGEROUS_NoTeamIdCheckTable, DateTimeDatabaseField, FieldOrTable, IntegerDatabaseField, @@ -38,3 +39,23 @@ def to_printed_clickhouse(self, context): def to_printed_hogql(self): return "logs" + + +class LogsKafkaMetricsTable(DANGEROUS_NoTeamIdCheckTable): + """ + Table stores meta information about kafka consumption _not_ scoped to teams + + This is so we can find out the overall lag per partition and filter live logs accordingly + """ + + fields: dict[str, FieldOrTable] = { + "_partition": IntegerDatabaseField(name="_partition", nullable=False), + "_topic": StringDatabaseField(name="_topic", nullable=False), + "max_observed_timestamp": DateTimeDatabaseField(name="max_observed_timestamp", nullable=False), + } + + def to_printed_clickhouse(self, context): + return "logs_kafka_metrics" + + def to_printed_hogql(self): + return "logs_kafka_metrics" diff --git a/posthog/schema.py b/posthog/schema.py index bec8900b6b0a0..9580cf4be6604 100644 --- a/posthog/schema.py +++ b/posthog/schema.py @@ -14151,6 +14151,7 @@ class LogsQuery(BaseModel): severityLevels: list[LogSeverityLevel] tags: QueryLogTags | None = None version: float | None = Field(default=None, description="version of the node, used for schema migrations") + liveLogsCheckpoint: str | None = None class QueryResponseAlternative18(BaseModel): diff --git a/products/logs/backend/api.py b/products/logs/backend/api.py index 45bf6e37d5313..c8247ee15cec1 100644 --- a/products/logs/backend/api.py +++ b/products/logs/backend/api.py @@ -26,6 +26,7 @@ def query(self, request: Request, *args, **kwargs) -> Response: if query_data is None: return Response({"error": "No query provided"}, status=status.HTTP_400_BAD_REQUEST) + live_logs_checkpoint = query_data.get("liveLogsCheckpoint", None) date_range = self.get_model(query_data.get("dateRange"), DateRange) requested_limit = min(query_data.get("limit", 1000), 2000) logs_query_params = { @@ -37,6 +38,8 @@ def query(self, request: Request, *args, **kwargs) -> Response: "filterGroup": query_data.get("filterGroup", None), "limit": requested_limit + 1, # Fetch limit plus 1 to see if theres another page } + if live_logs_checkpoint: + logs_query_params["liveLogsCheckpoint"] = live_logs_checkpoint query = LogsQuery(**logs_query_params) def results_generator(query: LogsQuery, logs_query_params: dict): @@ -106,6 +109,13 @@ def runner_slice( return LogsQueryRunner(slice_query, self.team), LogsQueryRunner(remainder_query, self.team) + # if we're live tailing don't do the runner slicing optimisations + # we're always only looking at the most recent 1 or 2 minutes of observed data + # which should cut things down more than the slicing anyway + if live_logs_checkpoint: + response = runner.run(ExecutionMode.CALCULATE_BLOCKING_ALWAYS) + yield from response.results + return # if we're searching more than 20 minutes, first fetch the first 3 minutes of logs and see if that hits the limit if date_range_length > dt.timedelta(minutes=20): recent_runner, runner = runner_slice(runner, dt.timedelta(minutes=3), query.orderBy) diff --git a/products/logs/backend/logs_query_runner.py b/products/logs/backend/logs_query_runner.py index 6be2bdb3deae5..339cd30c857ec 100644 --- a/products/logs/backend/logs_query_runner.py +++ b/products/logs/backend/logs_query_runner.py @@ -100,7 +100,6 @@ def _calculate(self) -> LogsQueryResponse: filters=HogQLFilters(dateRange=self.query.dateRange), settings=self.settings, ) - results = [] for result in response.results: results.append( @@ -118,6 +117,7 @@ def _calculate(self) -> LogsQueryResponse: "resource_attributes": result[10], "instrumentation_scope": result[11], "event_name": result[12], + "live_logs_checkpoint": result[13], } ) @@ -161,7 +161,8 @@ def to_query(self) -> ast.SelectQuery: severity_text as level, resource_attributes, instrumentation_scope, - event_name + event_name, + (select min(max_observed_timestamp) from logs_kafka_metrics) as live_logs_checkpoint FROM logs where (_part_starting_offset+_part_offset) in ({query}) """, placeholders={"query": query}, @@ -225,6 +226,14 @@ def where(self): if self.query.filterGroup: exprs.append(property_to_expr(self.query.filterGroup, team=self.team)) + if self.query.liveLogsCheckpoint: + exprs.append( + parse_expr( + "observed_timestamp >= {liveLogsCheckpoint}", + placeholders={"liveLogsCheckpoint": ast.Constant(value=self.query.liveLogsCheckpoint)}, + ) + ) + return ast.And(exprs=exprs) @cached_property diff --git a/products/logs/frontend/LogsScene.tsx b/products/logs/frontend/LogsScene.tsx index e445ef7f90322..12f8f6d71b6c1 100644 --- a/products/logs/frontend/LogsScene.tsx +++ b/products/logs/frontend/LogsScene.tsx @@ -29,6 +29,7 @@ import { TZLabel, TZLabelProps } from 'lib/components/TZLabel' import { ListHog } from 'lib/components/hedgehogs' import { LemonField } from 'lib/lemon-ui/LemonField' import { humanFriendlyNumber } from 'lib/utils' +import { IconPauseCircle, IconPlayCircle } from 'lib/lemon-ui/icons' import { cn } from 'lib/utils/css-classes' import { Scene, SceneExport } from 'scenes/sceneTypes' import { sceneConfigurations } from 'scenes/scenes' @@ -233,7 +234,9 @@ function LogsTable({ size="small" embedded rowKey="uuid" - rowStatus={(record) => (record.uuid === highlightedLogId ? 'highlighted' : null)} + rowStatus={(record) => + record.uuid === highlightedLogId ? 'highlighted' : record.new ? 'highlight-new' : null + } rowClassName={(record) => isPinned(record.uuid) ? cn('bg-primary-highlight', showPinnedWithOpacity && 'opacity-50') : 'group' } @@ -412,8 +415,8 @@ const LogTag = ({ level }: { level: LogMessage['severity_text'] }): JSX.Element } const Filters = (): JSX.Element => { - const { logsLoading } = useValues(logsLogic) - const { runQuery, zoomDateRange } = useActions(logsLogic) + const { logsLoading, liveTailRunning, liveTailDisabledReason } = useValues(logsLogic) + const { runQuery, zoomDateRange, setLiveTailRunning } = useActions(logsLogic) return (
@@ -441,9 +444,19 @@ const Filters = (): JSX.Element => { icon={} type="secondary" onClick={() => runQuery()} - loading={logsLoading} + loading={logsLoading || liveTailRunning} + disabledReason={liveTailRunning ? 'Disable live tail to manually refresh' : undefined} + > + {liveTailRunning ? 'Tailing...' : logsLoading ? 'Loading...' : 'Search'} + + : } + onClick={() => setLiveTailRunning(!liveTailRunning)} + disabledReason={liveTailRunning ? undefined : liveTailDisabledReason} > - {logsLoading ? 'Loading...' : 'Search'} + Live tail
diff --git a/products/logs/frontend/logsLogic.tsx b/products/logs/frontend/logsLogic.tsx index b5b6ca2ee4246..48c6a545ab662 100644 --- a/products/logs/frontend/logsLogic.tsx +++ b/products/logs/frontend/logsLogic.tsx @@ -1,6 +1,6 @@ import colors from 'ansi-colors' import equal from 'fast-deep-equal' -import { actions, kea, listeners, path, props, reducers, selectors } from 'kea' +import { actions, events, kea, listeners, path, props, reducers, selectors } from 'kea' import { loaders } from 'kea-loaders' import { router } from 'kea-router' @@ -34,6 +34,8 @@ const DEFAULT_PRETTIFY_JSON = true const DEFAULT_TIMESTAMP_FORMAT = 'absolute' as 'absolute' | 'relative' const DEFAULT_LOGS_PAGE_SIZE: number = 100 const NEW_QUERY_STARTED_ERROR_MESSAGE = 'new query started' as const +const DEFAULT_LIVE_TAIL_POLL_INTERVAL_MS = 1000 +const DEFAULT_LIVE_TAIL_POLL_INTERVAL_MAX_MS = 5000 const parseLogAttributes = (logs: LogMessage[]): void => { logs.forEach((row) => { @@ -185,10 +187,14 @@ export const logsLogic = kea([ clearLogs: true, cancelInProgressLogs: (logsAbortController: AbortController | null) => ({ logsAbortController }), cancelInProgressSparkline: (sparklineAbortController: AbortController | null) => ({ sparklineAbortController }), + cancelInProgressLiveTail: (liveTailAbortController: AbortController | null) => ({ liveTailAbortController }), setLogsAbortController: (logsAbortController: AbortController | null) => ({ logsAbortController }), setSparklineAbortController: (sparklineAbortController: AbortController | null) => ({ sparklineAbortController, }), + setLiveTailAbortController: (liveTailAbortController: AbortController | null) => ({ + liveTailAbortController, + }), setDateRange: (dateRange: DateRange) => ({ dateRange }), setOrderBy: (orderBy: LogsQuery['orderBy']) => ({ orderBy }), setSearchTerm: (searchTerm: LogsQuery['searchTerm']) => ({ searchTerm }), @@ -196,6 +202,8 @@ export const logsLogic = kea([ setServiceNames: (serviceNames: LogsQuery['serviceNames']) => ({ serviceNames }), setWrapBody: (wrapBody: boolean) => ({ wrapBody }), setPrettifyJson: (prettifyJson: boolean) => ({ prettifyJson }), + setLiveLogsCheckpoint: (liveLogsCheckpoint: string | null) => ({ liveLogsCheckpoint }), + setFilterGroup: (filterGroup: UniversalFiltersGroup, openFilterOnInsert: boolean = true) => ({ filterGroup, openFilterOnInsert, @@ -216,6 +224,14 @@ export const logsLogic = kea([ setHighlightedLogId: (highlightedLogId: string | null) => ({ highlightedLogId }), setHasMoreLogsToLoad: (hasMoreLogsToLoad: boolean) => ({ hasMoreLogsToLoad }), setLogsPageSize: (logsPageSize: number) => ({ logsPageSize }), + setLiveTailRunning: (enabled: boolean) => ({ enabled }), + setLiveTailInterval: (interval: number) => ({ interval }), + pollForNewLogs: true, + setLogs: (logs: LogMessage[]) => ({ logs }), + setSparkline: (sparkline: any[]) => ({ sparkline }), + expireLiveTail: () => true, + setLiveTailExpired: (liveTailExpired: boolean) => ({ liveTailExpired }), + addLogsToSparkline: (logs: LogMessage[]) => logs, }), reducers({ @@ -267,6 +283,21 @@ export const logsLogic = kea([ setWrapBody: (_, { wrapBody }) => wrapBody, }, ], + liveLogsCheckpoint: [ + null as string | null, + { persist: false }, + { + setLiveLogsCheckpoint: (_, { liveLogsCheckpoint }) => liveLogsCheckpoint, + }, + ], + liveTailExpired: [ + true as boolean, + { persist: false }, + { + setLiveTailExpired: (_, { liveTailExpired }) => liveTailExpired, + fetchLogsSuccess: () => false, + }, + ], prettifyJson: [ DEFAULT_PRETTIFY_JSON as boolean, { @@ -291,6 +322,12 @@ export const logsLogic = kea([ setSparklineAbortController: (_, { sparklineAbortController }) => sparklineAbortController, }, ], + liveTailAbortController: [ + null as AbortController | null, + { + setLiveTailAbortController: (_, { liveTailAbortController }) => liveTailAbortController, + }, + ], hasRunQuery: [ false as boolean, { @@ -338,6 +375,19 @@ export const logsLogic = kea([ unpinLog: (state, { logId }) => state.filter((log) => log.uuid !== logId), }, ], + liveTailRunning: [ + false as boolean, + { + setLiveTailRunning: (_, { enabled }) => enabled, + runQuery: () => false, + }, + ], + liveTailPollInterval: [ + DEFAULT_LIVE_TAIL_POLL_INTERVAL_MS as number, + { + setLiveTailInterval: (_, { interval }) => interval, + }, + ], highlightedLogId: [ DEFAULT_HIGHLIGHTED_LOG_ID, { @@ -423,6 +473,7 @@ export const logsLogic = kea([ parseLogAttributes(response.results) return [...values.logs, ...response.results] }, + setLogs: ({ logs }) => logs, }, ], sparkline: [ @@ -447,12 +498,39 @@ export const logsLogic = kea([ actions.setSparklineAbortController(null) return response }, + setSparkline: ({ sparkline }) => sparkline, }, ], })), - selectors({ + selectors(() => ({ tabId: [(_, p) => [p.tabId], (tabId: string) => tabId], + liveTailDisabledReason: [ + ( + orderBy: LogsQuery['orderBy'], + dateRange: DateRange, + logsLoading: boolean, + liveTailExpired: boolean + ): string | undefined => { + if (orderBy !== 'latest') { + return 'Live tail only works with "Latest" ordering' + } + + if (dateRange.date_to) { + return 'Live tail requires an open-ended time range' + } + + if (logsLoading) { + return 'Wait for query to finish' + } + + if (liveTailExpired) { + return 'Live tail has expired, run search again to live tail' + } + + return undefined + }, + ], utcDateRange: [ (s) => [s.dateRange], (dateRange) => ({ @@ -527,7 +605,7 @@ export const logsLogic = kea([ ], sparklineData: [ (s) => [s.sparkline], - (sparkline) => { + (sparkline: any[]) => { let lastTime = '' let i = -1 const labels: string[] = [] @@ -546,9 +624,9 @@ export const logsLogic = kea([ } const key = currentItem.level if (!accumulator[key]) { - accumulator[key] = Array(sparkline.length) + accumulator[key] = [...Array(sparkline.length)].map(() => 0) } - accumulator[key][i] = currentItem.count + accumulator[key][i] += currentItem.count return accumulator }, {}) ) @@ -605,7 +683,7 @@ export const logsLogic = kea([ ], }), - listeners(({ values, actions }) => ({ + listeners(({ values, actions, cache }) => ({ fetchLogsFailure: ({ error }) => { if (error !== NEW_QUERY_STARTED_ERROR_MESSAGE) { lemonToast.error(`Failed to load logs: ${error}`) @@ -623,6 +701,7 @@ export const logsLogic = kea([ actions.clearLogs() actions.fetchLogs() actions.fetchSparkline() + actions.cancelInProgressLiveTail(null) }, cancelInProgressLogs: ({ logsAbortController }) => { if (values.logsAbortController !== null) { @@ -636,6 +715,13 @@ export const logsLogic = kea([ } actions.setSparklineAbortController(sparklineAbortController) }, + cancelInProgressLiveTail: ({ liveTailAbortController }) => { + if (values.liveTailAbortController !== null) { + values.liveTailAbortController.abort('live tail request cancelled') + } + actions.setLiveTailAbortController(liveTailAbortController) + cache.disposables.dispose('liveTailTimer') + }, toggleAttributeBreakdown: ({ key }) => { const breakdowns = [...values.expandedAttributeBreaksdowns] const index = breakdowns.indexOf(key) @@ -662,6 +748,13 @@ export const logsLogic = kea([ } actions.setDateRange(newDateRange) }, + expireLiveTail: async ({}, breakpoint) => { + await breakpoint(30000) + if (values.liveTailRunning) { + return + } + actions.setLiveTailExpired(true) + }, addFilter: ({ key, value, operator }) => { const currentGroup = values.filterGroup.values[0] as UniversalFiltersGroup @@ -704,5 +797,146 @@ export const logsLogic = kea([ loadMoreLogs: () => { actions.fetchNextLogsPage() }, + setLiveTailRunning: async ({ enabled }) => { + if (enabled) { + actions.pollForNewLogs() + } else { + actions.cancelInProgressLiveTail(null) + actions.expireLiveTail() + } + }, + pollForNewLogs: async () => { + if (!values.liveTailRunning || values.orderBy !== 'latest' || document.hidden) { + return + } + + const liveTailController = new AbortController() + const signal = liveTailController.signal + actions.cancelInProgressLiveTail(liveTailController) + let duration = 0 + + try { + const start = Date.now() + const response = await api.logs.query({ + query: { + limit: values.logsPageSize, + orderBy: values.orderBy, + dateRange: values.utcDateRange, + searchTerm: values.searchTerm, + filterGroup: values.filterGroup as PropertyGroupFilter, + severityLevels: values.severityLevels, + serviceNames: values.serviceNames, + liveLogsCheckpoint: values.liveLogsCheckpoint ?? undefined, + }, + signal, + }) + duration = Date.now() - start + + if (response.results.length > 0) { + // the live_logs_checkpoint is the latest known timestamp for which we know we have all logs up to that point + // it's returned from clickhouse as a value on every log row - but the value is fixed per query + actions.setLiveLogsCheckpoint(response.results[0].live_logs_checkpoint ?? null) + } + + response.results.forEach((row) => { + Object.keys(row.attributes).forEach((key) => { + const value = row.attributes[key] + row.attributes[key] = typeof value === 'string' ? value : JSON.stringify(value) + }) + }) + + const existingUuids = new Set(values.logs.map((log) => log.uuid)) + const newLogs = response.results.filter((log) => !existingUuids.has(log.uuid)) + + if (newLogs.length > 0) { + actions.setLiveTailInterval(DEFAULT_LIVE_TAIL_POLL_INTERVAL_MS) + actions.setLogs( + [ + ...newLogs.map((log) => ({ ...log, new: true })), + ...values.logs.map((log) => ({ ...log, new: false })), + ] + .sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp)) + .slice(0, values.logsPageSize) + ) + actions.addLogsToSparkline(newLogs) + } else { + const newInterval = Math.min( + values.liveTailPollInterval * 1.5, + DEFAULT_LIVE_TAIL_POLL_INTERVAL_MAX_MS + ) + actions.setLiveTailInterval(newInterval) + } + } catch (error) { + if (signal.aborted) { + return + } + console.error('Live tail polling error:', error) + actions.setLiveTailRunning(false) + } finally { + actions.setLiveTailAbortController(null) + if (values.liveTailRunning) { + cache.disposables.add(() => { + const timerId = setTimeout( + () => { + actions.pollForNewLogs() + }, + Math.max(duration, values.liveTailPollInterval) + ) + return () => clearTimeout(timerId) + }, 'liveTailTimer') + } + } + }, + // insert logs into the sparkline data + addLogsToSparkline: (logs: LogMessage[]) => { + // if the sparkline hasn't loaded do nothing. + if (!values.sparkline || values.sparkline.length < 2) { + return + } + + const first_bucket = values.sparklineData.dates[0] + const last_bucket = values.sparklineData.dates[values.sparklineData.dates.length - 1] + const sparklineTimeWindow = dayjs(last_bucket).diff(first_bucket, 'seconds') + const interval = dayjs(values.sparklineData.dates[1]).diff(first_bucket, 'seconds') + let latest_time_bucket = dayjs(last_bucket) + + const sparklineMap: Map = new Map() + + for (const bucket of values.sparkline) { + const key = `${dayjs(bucket.time).toISOString()}_${bucket.level}` + sparklineMap.set(key, { ...bucket }) + } + + for (const log of logs) { + const time_bucket = dayjs.unix(Math.floor(dayjs(log.timestamp).unix() / interval) * interval) + if (time_bucket.isAfter(latest_time_bucket)) { + latest_time_bucket = time_bucket + } + const key = `${time_bucket.toISOString()}_${log.level}` + if (sparklineMap.has(key)) { + sparklineMap.get(key)!.count += 1 + } else { + sparklineMap.set(key, { time: time_bucket.toISOString(), level: log.level, count: 1 }) + } + } + actions.setSparkline( + Array.from(sparklineMap.values()) + .sort((a, b) => dayjs(a.time).diff(dayjs(b.time)) || a.level.localeCompare(b.level)) + .filter((item) => latest_time_bucket.diff(dayjs(item.time), 'seconds') <= sparklineTimeWindow) + ) + }, + })), + + events(({ values, actions }) => ({ + beforeUnmount: () => { + actions.setLiveTailRunning(false) + actions.cancelInProgressLiveTail(null) + if (values.logsAbortController) { + values.logsAbortController.abort('unmounting component') + } + if (values.sparklineAbortController) { + values.sparklineAbortController.abort('unmounting component') + } + }, })), ]) From 2292da5001dea18d638c65478513e37bc71dabea Mon Sep 17 00:00:00 2001 From: Frank Hamand Date: Thu, 27 Nov 2025 19:32:42 +0000 Subject: [PATCH 2/4] fix rebase error --- products/logs/frontend/logsLogic.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/products/logs/frontend/logsLogic.tsx b/products/logs/frontend/logsLogic.tsx index 48c6a545ab662..61f73c7283093 100644 --- a/products/logs/frontend/logsLogic.tsx +++ b/products/logs/frontend/logsLogic.tsx @@ -503,7 +503,7 @@ export const logsLogic = kea([ ], })), - selectors(() => ({ + selectors({ tabId: [(_, p) => [p.tabId], (tabId: string) => tabId], liveTailDisabledReason: [ ( From 63bd83e11b5592a2ffb71eae76fa903533ba05c3 Mon Sep 17 00:00:00 2001 From: Frank Hamand Date: Fri, 28 Nov 2025 09:25:27 +0000 Subject: [PATCH 3/4] fix schema --- posthog/schema.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/posthog/schema.py b/posthog/schema.py index 9580cf4be6604..fa29e79730a8e 100644 --- a/posthog/schema.py +++ b/posthog/schema.py @@ -4608,6 +4608,8 @@ class LogMessage(BaseModel): event_name: str instrumentation_scope: str level: LogSeverityLevel + live_logs_checkpoint: AwareDatetime | None = None + new: bool | None = None observed_timestamp: AwareDatetime resource_attributes: Any severity_number: float @@ -14142,6 +14144,7 @@ class LogsQuery(BaseModel): filterGroup: PropertyGroupFilter kind: Literal["LogsQuery"] = "LogsQuery" limit: int | None = None + liveLogsCheckpoint: str | None = None modifiers: HogQLQueryModifiers | None = Field(default=None, description="Modifiers used when performing the query") offset: int | None = None orderBy: OrderBy3 | None = None @@ -14151,7 +14154,6 @@ class LogsQuery(BaseModel): severityLevels: list[LogSeverityLevel] tags: QueryLogTags | None = None version: float | None = Field(default=None, description="version of the node, used for schema migrations") - liveLogsCheckpoint: str | None = None class QueryResponseAlternative18(BaseModel): From 2fd03706805d391d24bfd68412d3b58b4ee73452 Mon Sep 17 00:00:00 2001 From: Frank Hamand Date: Fri, 28 Nov 2025 10:07:28 +0000 Subject: [PATCH 4/4] formatting --- products/logs/frontend/LogsScene.tsx | 2 +- products/logs/frontend/logsLogic.tsx | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/products/logs/frontend/LogsScene.tsx b/products/logs/frontend/LogsScene.tsx index 12f8f6d71b6c1..f5cbc0c63c49b 100644 --- a/products/logs/frontend/LogsScene.tsx +++ b/products/logs/frontend/LogsScene.tsx @@ -28,8 +28,8 @@ import { Sparkline } from 'lib/components/Sparkline' import { TZLabel, TZLabelProps } from 'lib/components/TZLabel' import { ListHog } from 'lib/components/hedgehogs' import { LemonField } from 'lib/lemon-ui/LemonField' -import { humanFriendlyNumber } from 'lib/utils' import { IconPauseCircle, IconPlayCircle } from 'lib/lemon-ui/icons' +import { humanFriendlyNumber } from 'lib/utils' import { cn } from 'lib/utils/css-classes' import { Scene, SceneExport } from 'scenes/sceneTypes' import { sceneConfigurations } from 'scenes/scenes' diff --git a/products/logs/frontend/logsLogic.tsx b/products/logs/frontend/logsLogic.tsx index 61f73c7283093..df1bafa5a5703 100644 --- a/products/logs/frontend/logsLogic.tsx +++ b/products/logs/frontend/logsLogic.tsx @@ -506,6 +506,7 @@ export const logsLogic = kea([ selectors({ tabId: [(_, p) => [p.tabId], (tabId: string) => tabId], liveTailDisabledReason: [ + (s) => [s.orderBy, s.dateRange, s.logsLoading, s.liveTailExpired], ( orderBy: LogsQuery['orderBy'], dateRange: DateRange,